From 2b34e81fbc8f41b9d6762a0e0c9d7525647cf73c Mon Sep 17 00:00:00 2001 From: dhraghuw Date: Fri, 7 Aug 2026 18:50:11 +0530 Subject: [PATCH] 4 hr of codex work --- .github/workflows/ci.yml | 91 ++ .gitignore | 8 + Makefile | 43 + README.md | 121 ++ interviewhub-backend-go/.dockerignore | 17 + interviewhub-backend-go/.env.example | 25 +- .../.github/workflows/ci.yml | 16 - interviewhub-backend-go/AGENTS.md | 2 +- interviewhub-backend-go/API_DOCUMENTATION.md | 385 +++-- interviewhub-backend-go/Dockerfile | 48 +- interviewhub-backend-go/Dockerfile.worker | 3 - interviewhub-backend-go/README.md | 191 ++- .../cmd/oauth-bind/main.go | 79 + interviewhub-backend-go/cmd/seed/main.go | 14 +- interviewhub-backend-go/cmd/server/main.go | 194 +-- interviewhub-backend-go/docker-compose.yml | 58 +- .../docs/production-architecture.md | 131 +- interviewhub-backend-go/go.mod | 15 +- interviewhub-backend-go/go.sum | 7 + .../internal/app/application.go | 97 ++ .../internal/config/config.go | 145 +- .../internal/config/config_test.go | 122 ++ .../internal/data/seeds.go | 33 +- .../internal/data/seeds_test.go | 357 +++++ .../internal/data/starter_files.go | 905 +++++++++++ .../internal/handlers/ai_handlers.go | 44 + .../internal/handlers/auth_handlers.go | 266 ++++ .../auth_workspace_error_contracts_test.go | 447 ++++++ .../internal/handlers/execution_handlers.go | 156 ++ .../internal/handlers/handlers.go | 414 +---- .../internal/handlers/health_handlers.go | 52 + .../internal/handlers/health_handlers_test.go | 68 + .../internal/handlers/middleware.go | 25 +- .../internal/handlers/oauth_handlers.go | 125 +- .../handlers/problem_error_contracts_test.go | 123 ++ .../internal/handlers/problem_handlers.go | 103 ++ .../handlers/refresh_security_test.go | 124 ++ .../internal/handlers/request.go | 76 + .../internal/handlers/request_test.go | 37 + .../internal/handlers/security.go | 15 +- .../handlers/submission_handlers_test.go | 116 ++ .../internal/handlers/workspace_handlers.go | 80 + .../internal/httpapi/router.go | 99 ++ .../internal/models/models.go | 127 +- .../internal/models/workspace.go | 10 +- .../internal/services/ai_service.go | 227 ++- .../internal/services/ai_service_test.go | 63 + .../internal/services/auth_service.go | 236 ++- .../internal/services/errors.go | 46 + .../internal/services/execution_evaluation.go | 359 +++++ .../internal/services/execution_service.go | 519 +++---- .../services/execution_service_test.go | 719 +++++++++ .../internal/services/execution_validation.go | 165 ++ .../internal/services/execution_worker.go | 212 +++ .../internal/services/local_program_runner.go | 341 +++++ .../services/local_program_runner_test.go | 136 ++ .../internal/services/oauth_service.go | 284 +++- .../internal/services/oauth_service_test.go | 107 ++ .../services/persistence_errors_test.go | 433 ++++++ .../services/problem_read_integrity_test.go | 164 ++ .../internal/services/problem_service.go | 224 ++- .../internal/services/problem_service_test.go | 126 ++ .../services/refresh_security_test.go | 79 + .../internal/services/submission_service.go | 367 ++++- .../services/submission_service_test.go | 205 +++ .../internal/services/user_service.go | 30 +- .../internal/services/workspace_service.go | 152 +- .../services/workspace_service_test.go | 98 ++ .../internal/store/auth_retention.go | 21 + .../store/auth_retention_postgres_test.go | 149 ++ .../005_problem_test_case_identity.sql | 8 + .../migrations/006_workspace_revisions.sql | 7 + .../007_auth_artifact_retention.sql | 4 + .../store/migrations/008_oauth_identities.sql | 15 + .../009_history_and_delete_safety.sql | 37 + .../migrations/010_submission_read_model.sql | 43 + .../internal/store/postgres.go | 717 ++++++++- .../internal/store/postgres_problems.go | 758 +++++++-- .../internal/store/postgres_problems_test.go | 89 ++ .../internal/store/postgres_runtime.go | 15 + .../internal/store/postgres_runtime_test.go | 55 + .../internal/store/problem_errors.go | 14 + .../store/refresh_family_query_test.go | 51 + .../internal/store/repository.go | 129 +- .../internal/store/seed.go | 195 ++- .../internal/store/seed_migrations_test.go | 109 ++ .../internal/store/store.go | 732 ++++++--- .../internal/store/store_test.go | 717 +++++++++ interviewhub-backend-go/scripts/seed-db.sh | 23 +- interviewhub-backend-go/test/api_test.go | 290 +++- interviewhub-backend-go/test/oauth_test.go | 138 +- interviewhub-backend-go/test/problems.json | 1354 ----------------- .../test/refresh_tokens.json | 38 - interviewhub-backend-go/test/users.json | 32 - interviewhub-lld/.dockerignore | 11 + interviewhub-lld/Dockerfile | 30 +- interviewhub-lld/README.md | 79 +- interviewhub-lld/nginx.conf | 55 + interviewhub-lld/package.json | 7 +- interviewhub-lld/scripts/verify-e2e.mjs | 199 +++ .../scripts/verify-session-boundaries.mjs | 260 ++++ interviewhub-lld/src/App.tsx | 1043 +------------ interviewhub-lld/src/app/App.tsx | 1093 +++++++++++++ interviewhub-lld/src/components/AuthPage.tsx | 43 +- .../src/components/BottomConsole.tsx | 18 +- .../src/components/ProfilePage.tsx | 1 + interviewhub-lld/src/components/StatusBar.tsx | 50 +- interviewhub-lld/src/components/TopNav.tsx | 3 +- .../src/components/admin/AdminPortal.tsx | 177 ++- interviewhub-lld/src/features/admin/api.ts | 37 + interviewhub-lld/src/features/ai/api.ts | 13 + interviewhub-lld/src/features/auth/api.ts | 146 ++ .../src/features/auth/useSession.ts | 246 +++ .../src/features/execution/api.ts | 144 ++ .../execution/hooks/useExecutionJob.ts | 52 + interviewhub-lld/src/features/problems/api.ts | 14 + .../src/features/submissions/api.ts | 64 + .../src/features/workspace/api.ts | 34 + .../features/workspace/hooks/useWorkspace.ts | 585 +++++++ .../src/features/workspace/lib/fileTree.ts | 92 ++ interviewhub-lld/src/services/apiClient.ts | 268 ++-- interviewhub-lld/src/shared/api/httpClient.ts | 307 ++++ interviewhub-lld/src/shared/api/index.ts | 4 + interviewhub-lld/src/shared/api/tokenStore.ts | 550 +++++++ interviewhub-lld/src/shared/api/types.ts | 25 + interviewhub-lld/src/types/index.ts | 2 + interviewhub-lld/src/vite-env.d.ts | 1 + interviewhub-lld/verify_auth.mjs | 95 -- interviewhub-lld/verify_browser.cjs | 66 - interviewhub-lld/verify_browser.mjs | 66 - .../verify_interactive_browser.mjs | 108 -- interviewhub-lld/vite.config.ts | 18 + run-local.sh | 537 ++++++- stop-local.sh | 295 +++- 134 files changed, 18234 insertions(+), 5250 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 Makefile create mode 100644 README.md create mode 100644 interviewhub-backend-go/.dockerignore delete mode 100644 interviewhub-backend-go/.github/workflows/ci.yml delete mode 100644 interviewhub-backend-go/Dockerfile.worker create mode 100644 interviewhub-backend-go/cmd/oauth-bind/main.go create mode 100644 interviewhub-backend-go/internal/app/application.go create mode 100644 interviewhub-backend-go/internal/config/config_test.go create mode 100644 interviewhub-backend-go/internal/data/seeds_test.go create mode 100644 interviewhub-backend-go/internal/data/starter_files.go create mode 100644 interviewhub-backend-go/internal/handlers/ai_handlers.go create mode 100644 interviewhub-backend-go/internal/handlers/auth_handlers.go create mode 100644 interviewhub-backend-go/internal/handlers/auth_workspace_error_contracts_test.go create mode 100644 interviewhub-backend-go/internal/handlers/execution_handlers.go create mode 100644 interviewhub-backend-go/internal/handlers/health_handlers.go create mode 100644 interviewhub-backend-go/internal/handlers/health_handlers_test.go create mode 100644 interviewhub-backend-go/internal/handlers/problem_error_contracts_test.go create mode 100644 interviewhub-backend-go/internal/handlers/problem_handlers.go create mode 100644 interviewhub-backend-go/internal/handlers/refresh_security_test.go create mode 100644 interviewhub-backend-go/internal/handlers/request.go create mode 100644 interviewhub-backend-go/internal/handlers/request_test.go create mode 100644 interviewhub-backend-go/internal/handlers/submission_handlers_test.go create mode 100644 interviewhub-backend-go/internal/handlers/workspace_handlers.go create mode 100644 interviewhub-backend-go/internal/httpapi/router.go create mode 100644 interviewhub-backend-go/internal/services/ai_service_test.go create mode 100644 interviewhub-backend-go/internal/services/errors.go create mode 100644 interviewhub-backend-go/internal/services/execution_evaluation.go create mode 100644 interviewhub-backend-go/internal/services/execution_service_test.go create mode 100644 interviewhub-backend-go/internal/services/execution_validation.go create mode 100644 interviewhub-backend-go/internal/services/execution_worker.go create mode 100644 interviewhub-backend-go/internal/services/local_program_runner.go create mode 100644 interviewhub-backend-go/internal/services/local_program_runner_test.go create mode 100644 interviewhub-backend-go/internal/services/oauth_service_test.go create mode 100644 interviewhub-backend-go/internal/services/persistence_errors_test.go create mode 100644 interviewhub-backend-go/internal/services/problem_read_integrity_test.go create mode 100644 interviewhub-backend-go/internal/services/problem_service_test.go create mode 100644 interviewhub-backend-go/internal/services/refresh_security_test.go create mode 100644 interviewhub-backend-go/internal/services/submission_service_test.go create mode 100644 interviewhub-backend-go/internal/services/workspace_service_test.go create mode 100644 interviewhub-backend-go/internal/store/auth_retention.go create mode 100644 interviewhub-backend-go/internal/store/auth_retention_postgres_test.go create mode 100644 interviewhub-backend-go/internal/store/migrations/005_problem_test_case_identity.sql create mode 100644 interviewhub-backend-go/internal/store/migrations/006_workspace_revisions.sql create mode 100644 interviewhub-backend-go/internal/store/migrations/007_auth_artifact_retention.sql create mode 100644 interviewhub-backend-go/internal/store/migrations/008_oauth_identities.sql create mode 100644 interviewhub-backend-go/internal/store/migrations/009_history_and_delete_safety.sql create mode 100644 interviewhub-backend-go/internal/store/migrations/010_submission_read_model.sql create mode 100644 interviewhub-backend-go/internal/store/postgres_problems_test.go create mode 100644 interviewhub-backend-go/internal/store/postgres_runtime.go create mode 100644 interviewhub-backend-go/internal/store/postgres_runtime_test.go create mode 100644 interviewhub-backend-go/internal/store/problem_errors.go create mode 100644 interviewhub-backend-go/internal/store/refresh_family_query_test.go create mode 100644 interviewhub-backend-go/internal/store/seed_migrations_test.go create mode 100644 interviewhub-backend-go/internal/store/store_test.go delete mode 100644 interviewhub-backend-go/test/problems.json delete mode 100644 interviewhub-backend-go/test/refresh_tokens.json delete mode 100644 interviewhub-backend-go/test/users.json create mode 100644 interviewhub-lld/.dockerignore create mode 100644 interviewhub-lld/nginx.conf create mode 100644 interviewhub-lld/scripts/verify-e2e.mjs create mode 100644 interviewhub-lld/scripts/verify-session-boundaries.mjs create mode 100644 interviewhub-lld/src/app/App.tsx create mode 100644 interviewhub-lld/src/features/admin/api.ts create mode 100644 interviewhub-lld/src/features/ai/api.ts create mode 100644 interviewhub-lld/src/features/auth/api.ts create mode 100644 interviewhub-lld/src/features/auth/useSession.ts create mode 100644 interviewhub-lld/src/features/execution/api.ts create mode 100644 interviewhub-lld/src/features/execution/hooks/useExecutionJob.ts create mode 100644 interviewhub-lld/src/features/problems/api.ts create mode 100644 interviewhub-lld/src/features/submissions/api.ts create mode 100644 interviewhub-lld/src/features/workspace/api.ts create mode 100644 interviewhub-lld/src/features/workspace/hooks/useWorkspace.ts create mode 100644 interviewhub-lld/src/features/workspace/lib/fileTree.ts create mode 100644 interviewhub-lld/src/shared/api/httpClient.ts create mode 100644 interviewhub-lld/src/shared/api/index.ts create mode 100644 interviewhub-lld/src/shared/api/tokenStore.ts create mode 100644 interviewhub-lld/src/shared/api/types.ts create mode 100644 interviewhub-lld/src/vite-env.d.ts delete mode 100644 interviewhub-lld/verify_auth.mjs delete mode 100644 interviewhub-lld/verify_browser.cjs delete mode 100644 interviewhub-lld/verify_browser.mjs delete mode 100644 interviewhub-lld/verify_interactive_browser.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..e7088341 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,91 @@ +name: ci + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + backend: + name: Backend / Go + runs-on: ubuntu-latest + timeout-minutes: 15 + defaults: + run: + working-directory: interviewhub-backend-go + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: interviewhub-backend-go/go.mod + cache-dependency-path: interviewhub-backend-go/go.sum + - name: Install the supported TypeScript runtime + uses: actions/setup-node@v4 + with: + node-version: "22.12" + - name: Verify modules + run: go mod verify + - name: Check formatting + run: test -z "$(gofmt -l .)" + - name: Vet + run: go vet ./... + - name: Test + run: go test -coverprofile=coverage.out ./... + - name: Race detector + run: go test -race ./... + - name: Ensure tests did not modify fixtures + working-directory: . + run: git diff --exit-code + - name: Build + run: go build -trimpath ./cmd/server ./cmd/seed ./cmd/oauth-bind + + frontend: + name: Frontend / React + runs-on: ubuntu-latest + timeout-minutes: 15 + defaults: + run: + working-directory: interviewhub-lld + env: + PUPPETEER_SKIP_DOWNLOAD: "true" + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + cache-dependency-path: interviewhub-lld/package-lock.json + - name: Install locked dependencies + run: npm ci --no-audit --no-fund + - name: Typecheck + run: npm run typecheck + - name: Verify session isolation and revocation queue + run: npm run test:session-boundaries + - name: Check browser smoke script + run: node --check scripts/verify-e2e.mjs + - name: Build + run: npm run build + + platform: + name: Platform / Scripts and containers + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + - name: Validate shell scripts + run: | + bash -n run-local.sh stop-local.sh interviewhub-backend-go/scripts/seed-db.sh + shellcheck run-local.sh stop-local.sh interviewhub-backend-go/scripts/seed-db.sh + - name: Validate Compose + run: docker compose --file interviewhub-backend-go/docker-compose.yml --project-directory interviewhub-backend-go config --quiet + - name: Build backend image + run: docker build --file interviewhub-backend-go/Dockerfile --tag judgemycode-api:ci interviewhub-backend-go + - name: Build frontend image + run: docker build --file interviewhub-lld/Dockerfile --tag judgemycode-web:ci interviewhub-lld diff --git a/.gitignore b/.gitignore index 715423a9..48040e97 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # Environment variables (keep secrets out of git) .env .env.local +.env.* .env.*.local *.env !*.env.example @@ -28,8 +29,15 @@ temp_sandboxes/ # Logs and runtime files *.pid +*.pid.tmp logs/ *.log +.local-run/ +coverage.out +coverage.html +coverage/ +test-results/ +playwright-report/ npm-debug.log* yarn-debug.log* yarn-error.log* diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..ca31e2f0 --- /dev/null +++ b/Makefile @@ -0,0 +1,43 @@ +SHELL := /bin/bash +.DEFAULT_GOAL := help + +ROOT := $(CURDIR) +BACKEND_DIR := $(ROOT)/interviewhub-backend-go +FRONTEND_DIR := $(ROOT)/interviewhub-lld + +.PHONY: help setup dev stop stop-all check test build + +help: ## Show available commands + @awk 'BEGIN {FS = ":.*## "; printf "JudgeMyCode development commands:\n"} /^[a-zA-Z_-]+:.*## / {printf " %-10s %s\n", $$1, $$2}' $(MAKEFILE_LIST) + +setup: ## Prepare env files, PostgreSQL, migrations, seeds, and dependencies + ./run-local.sh --setup-only + +dev: ## Start the complete local development stack + ./run-local.sh + +stop: ## Stop API and frontend; leave PostgreSQL running + ./stop-local.sh + +stop-all: ## Stop API, frontend, and PostgreSQL without deleting data + ./stop-local.sh --dependencies + +check: ## Run static checks without producing build artifacts + bash -n run-local.sh stop-local.sh interviewhub-backend-go/scripts/seed-db.sh + @if command -v shellcheck >/dev/null 2>&1; then shellcheck run-local.sh stop-local.sh interviewhub-backend-go/scripts/seed-db.sh; else echo "shellcheck not installed; skipped"; fi + @unformatted="$$(cd "$(BACKEND_DIR)" && gofmt -l $$(find . -name '*.go' -not -path './vendor/*'))"; test -z "$$unformatted" || { printf 'gofmt required:\n%s\n' "$$unformatted"; exit 1; } + cd "$(BACKEND_DIR)" && go vet ./... + cd "$(FRONTEND_DIR)" && npm run typecheck + cd "$(FRONTEND_DIR)" && npm run test:session-boundaries + docker compose --file "$(BACKEND_DIR)/docker-compose.yml" --project-directory "$(BACKEND_DIR)" config --quiet + +test: ## Run backend tests, including the race detector + cd "$(BACKEND_DIR)" && go test ./... + cd "$(BACKEND_DIR)" && go test -race ./... + +build: ## Build backend and production frontend + mkdir -p "$(ROOT)/.local-run/bin" + cd "$(BACKEND_DIR)" && go build -trimpath -o "$(ROOT)/.local-run/bin/interviewhub-api" ./cmd/server + cd "$(BACKEND_DIR)" && go build -trimpath -o "$(ROOT)/.local-run/bin/interviewhub-seed" ./cmd/seed + cd "$(BACKEND_DIR)" && go build -trimpath -o "$(ROOT)/.local-run/bin/interviewhub-oauth-bind" ./cmd/oauth-bind + cd "$(FRONTEND_DIR)" && npm run build diff --git a/README.md b/README.md new file mode 100644 index 00000000..52a1bea4 --- /dev/null +++ b/README.md @@ -0,0 +1,121 @@ +# JudgeMyCode + +JudgeMyCode is a React/Vite interview workspace backed by a Go API and PostgreSQL. The supported local workflow runs PostgreSQL in Docker and runs the API and frontend directly on the host for fast reloads. + +## One-command local development + +Prerequisites: + +- Go 1.25 or newer compatible toolchain +- Node.js 22.12+ and npm +- Docker with Compose v2 +- `curl`, `lsof`, and `cksum` (included by default on macOS and most Unix systems) +- For every local execution language: Python 3, a JDK (`javac`/`java`), and a + C++17 compiler (`g++`). Missing toolchains do not prevent the application + from starting; that language reports `RUNTIME_UNAVAILABLE` when used. + +From this directory: + +```bash +./run-local.sh +``` + +The command is safe to repeat. It creates missing local environment defaults, starts and waits for PostgreSQL, applies migrations, explicitly loads the local demo users and problem catalog, installs locked frontend dependencies when needed, rebuilds/restarts the API and Vite processes, and waits for both HTTP endpoints. The explicit restart prevents a previous binary from serving stale source or configuration. + +Yes: `run-local.sh` starts the database dependency, backend, and frontend. To +start again after stopping—or to rebuild/restart after code changes—run the +same command: + +```bash +./run-local.sh +``` + +Use `./run-local.sh --setup-only` when you want dependencies, migrations, +seeds, and npm packages prepared without starting the API or frontend. + +| Service | Local address | +| --- | --- | +| Frontend | `http://127.0.0.1:5173` | +| API | `http://127.0.0.1:5050/api` | +| PostgreSQL | `127.0.0.1:5432` | + +Runtime logs are under `logs/`; validated PID files are `backend.pid` and `frontend.pid`. + +```bash +./stop-local.sh # API + frontend +./stop-local.sh --dependencies # also stop PostgreSQL; data volume is kept +``` + +The Makefile exposes the same workflow: + +```bash +make setup # dependencies, database, migrations, local seeds +make dev # complete local stack +make stop # application processes only +make stop-all # application + PostgreSQL +make check # shell, Go, TypeScript, and Compose checks +make test # Go tests and race detector +make build # API binary and production frontend bundle +``` + +## Repository layout + +- `interviewhub-backend-go/`: Go API with `internal/httpapi` as the composition/router boundary, embedded SQL migrations, PostgreSQL adapter, and local execution engine. +- `interviewhub-lld/`: React 19, TypeScript, Vite, and Monaco frontend organized around `src/app`, feature modules under `src/features`, and shared infrastructure under `src/shared`. +- `run-local.sh` / `stop-local.sh`: idempotent local lifecycle. +- `.github/workflows/ci.yml`: backend, frontend, script, Compose, and image checks. + +Execution is asynchronous: run/submit returns an execution ID, and the client polls the owned execution resource until it reaches `COMPLETED`, `FAILED`, or `TIMED_OUT`. + +Workspace autosave uses server revisions instead of timestamps. Every write is +compare-and-swap; a stale browser receives `409 Conflict` and must explicitly +keep its local draft or load the server version. Each open tab also keeps its +own recovery journal, so tabs cannot silently overwrite one another's unsaved +work. + +Administrator problem edits also use compare-and-swap with the problem's +`updatedAt` revision. The guarded statement update and starter/test replacement +are one PostgreSQL transaction, and catalog seed/reset operations share the +same mutation lock. A stale editor therefore receives `409 Conflict` instead +of silently replacing another administrator's work. + +A plain run executes once with custom standard input. A selected public test +runs with its server-owned input and expected output, “Run all tests” evaluates +all public tests, and submit evaluates public plus hidden tests before writing +submission history. Client-supplied assertions are never trusted. + +Submit jobs capture an immutable catalog/test snapshot at queue admission and +persist its SHA-256 `evaluationRevision`. Later administrator edits therefore +cannot change an in-flight grade, and historical assertion values are removed +from submission responses. + +Submission history uses owner-scoped keyset pagination (50 rows by default, +100 maximum) and lightweight indexed summaries; source files and execution +output are loaded only through the owner/admin detail endpoint. User progress, +including solved problem IDs, is calculated in one PostgreSQL aggregate rather +than by downloading and scanning a tenant's history in the browser. + +Refresh credentials rotate once, are serialized with logout at the database +family boundary, and retain the original absolute expiry. The browser uses a +cross-tab lock plus principal/login-epoch lineage, so concurrent tabs adopt one +replacement and a delayed request can never replay under a newly signed-in +account. Failed logouts are kept as independent per-credential records in a +bounded, deduplicated revocation queue, so concurrent tabs cannot lose one +another's work; the queue is retried at startup, when connectivity returns, on +cross-tab changes, and around later sign-ins. One required browser Web Lock +serializes every credential mutation; unsupported browsers fail closed instead +of relying on an expiring client lease. A separate drain lock keeps old network +revocations from blocking a new login, and each HTTP request carries the bearer +captured with its originating session and body. The API bounds refresh +and logout requests independently by source and hashed opaque credential, and +PostgreSQL family operations use the direct `family_id` index. + +AI feedback is optional. Configure an OpenAI-compatible chat-completions endpoint with `AI_API_URL`, `AI_API_KEY`, and `AI_MODEL`; `AI_REQUEST_TIMEOUT` defaults to `20s` and `AI_MAX_OUTPUT_TOKENS` to `700`. With no key/provider configured, the API returns a clear unavailable/configuration error and never fabricates feedback. Staging/production also requires `AI_EXTERNAL_GUARD=true` as an explicit acknowledgement that a distributed quota and spend guard exists upstream. + +## Security boundary + +`EXECUTION_ENABLED=true` is written only for the explicit local-development workflow. The current runner executes candidate programs on the API host and is not a security sandbox. Never expose it to untrusted users. Production must set `EXECUTION_ENABLED=false` until a dedicated network-disabled judge service with CPU, memory, process, filesystem, output, and wall-time isolation is implemented. + +The Compose file intentionally contains PostgreSQL only. Redis and the placeholder worker were removed because no durable queue/worker implementation exists yet. + +See [backend documentation](interviewhub-backend-go/README.md), [API documentation](interviewhub-backend-go/API_DOCUMENTATION.md), and the [production architecture contract](interviewhub-backend-go/docs/production-architecture.md). diff --git a/interviewhub-backend-go/.dockerignore b/interviewhub-backend-go/.dockerignore new file mode 100644 index 00000000..9afafbf7 --- /dev/null +++ b/interviewhub-backend-go/.dockerignore @@ -0,0 +1,17 @@ +.git +.github +.env +.env.* +data +test +logs +bin +temp_sandboxes +*.pid +*.log +coverage.out +Dockerfile.worker +docker-compose*.yml +README.md +API_DOCUMENTATION.md +docs diff --git a/interviewhub-backend-go/.env.example b/interviewhub-backend-go/.env.example index 399e5966..84c347e6 100644 --- a/interviewhub-backend-go/.env.example +++ b/interviewhub-backend-go/.env.example @@ -1,10 +1,14 @@ # Copy to .env locally; inject real values through your deployment platform. PORT=5050 +HOST=127.0.0.1 APP_ENV=development JWT_SECRET=replace-with-a-long-random-secret-at-least-32-characters ACCESS_TOKEN_TTL=15m REFRESH_TOKEN_TTL=720h ALLOWED_ORIGINS=http://localhost:5173,http://localhost:3000,http://127.0.0.1:5173 +# Comma-separated proxy networks allowed to supply X-Forwarded-For. Leave +# empty when clients connect directly; never trust an internet-wide CIDR. +TRUSTED_PROXY_CIDRS= MAX_REQUEST_BYTES=2097152 # Application Base URLs @@ -33,16 +37,23 @@ LINKEDIN_REDIRECT_URL=http://localhost:5050/api/auth/oauth/linkedin/callback # Local development database and queue settings. # Host-run API (go run ./cmd/server or ./scripts/seed-db.sh): DATABASE_URL=postgres://judgemycode:local-dev-password@localhost:5432/judgemycode?sslmode=disable -REDIS_URL=redis://localhost:6379/0 -# Inside docker compose, use hostnames `postgres` / `redis` instead of localhost. +# Docker Compose exposes PostgreSQL only on the loopback interface. +EXECUTION_ENABLED=false EXECUTION_RUNNER=local -EXECUTION_QUEUE_NAME=judge-jobs -EXECUTION_DOCKER_ENABLED=false +EXECUTION_QUEUE_CAPACITY=32 +EXECUTION_WORKER_COUNT=2 EXECUTION_TIMEOUT_SEC=6 EXECUTION_MAX_FILE_COUNT=50 EXECUTION_MAX_OUTPUT_BYTES=1048576 -EXECUTION_MAX_MEMORY_MB=256 -EXECUTION_MAX_PROCESSES=64 -EXECUTION_CPU_CORES=1 +# Optional OpenAI-compatible chat-completions provider. Leave blank to disable +# AI feedback; the API returns a configuration/unavailable error, never mocks. +AI_API_URL= +AI_API_KEY= +AI_MODEL= +AI_REQUEST_TIMEOUT=20s +AI_MAX_OUTPUT_TOKENS=700 +# Required in staging/production when AI is enabled; confirms ingress/provider +# enforces a distributed per-account quota and total spend budget. +AI_EXTERNAL_GUARD=false diff --git a/interviewhub-backend-go/.github/workflows/ci.yml b/interviewhub-backend-go/.github/workflows/ci.yml deleted file mode 100644 index 0d332f76..00000000 --- a/interviewhub-backend-go/.github/workflows/ci.yml +++ /dev/null @@ -1,16 +0,0 @@ -name: ci - -on: - push: - branches: [main] - pull_request: - -jobs: - test: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 - with: - go-version: '1.25' - - run: go test ./... diff --git a/interviewhub-backend-go/AGENTS.md b/interviewhub-backend-go/AGENTS.md index a84de911..4977c0be 100644 --- a/interviewhub-backend-go/AGENTS.md +++ b/interviewhub-backend-go/AGENTS.md @@ -10,7 +10,7 @@ Requests follow this path: - **handlers** validate HTTP requests, enforce HTTP status codes, and translate service errors into the standard response envelope. They do not contain business rules. - **services** own product rules: identity, progress calculations, workspace ownership, grading, and AI interactions. -- **store** is the persistence boundary. Production uses PostgreSQL exclusively. The in-memory/JSON adapter exists only for unit tests. +- **store** is the persistence boundary. Production uses PostgreSQL exclusively. A fresh in-memory adapter exists only for unit tests; runtime JSON persistence was removed. - **models** holds transport-safe domain structures shared by layers. Do not hardcode problem catalogs or user records in the API server. Bootstrap empty databases with `./scripts/seed-db.sh`. diff --git a/interviewhub-backend-go/API_DOCUMENTATION.md b/interviewhub-backend-go/API_DOCUMENTATION.md index ac64f27d..c84cdf95 100644 --- a/interviewhub-backend-go/API_DOCUMENTATION.md +++ b/interviewhub-backend-go/API_DOCUMENTATION.md @@ -1,13 +1,13 @@ # ⚡ InterviewHub / JudgeMyCode Backend API Documentation -High-performance, modular Low-Level Design (LLD) & Machine Coding Backend Engine written in **Go** (Golang) using Chi router, concurrent execution sandboxes, JWT authentication, and automated test grading. +Go API for the JudgeMyCode LLD workspace. It provides JWT/OAuth authentication, PostgreSQL-backed catalog and workspace data, and an asynchronous development execution queue. The current host runner is not a production security sandbox; see the production limitation below. --- ## 🌐 Base URL & Server Info -- **Base URL**: `http://localhost:8080/api` -- **Default Port**: `8080` (configurable via `PORT` environment variable) +- **Base URL**: `http://127.0.0.1:5050/api` +- **Default Port**: `5050` (configurable via `PORT` environment variable) - **Content-Type**: `application/json` - **Authentication Scheme**: `Bearer ` in `Authorization` header @@ -18,10 +18,14 @@ High-performance, modular Low-Level Design (LLD) & Machine Coding Backend Engine | Method | Endpoint | Auth Required | Description | | :--- | :--- | :--- | :--- | | `GET` | `/api/health` | ❌ No | Health check and engine status | -| `POST` | `/api/auth/register` | ❌ No | Register new candidate or admin | +| `GET` | `/api/health/ready` | ❌ No | Dependency-aware readiness check | +| `POST` | `/api/auth/register` | ❌ No | Register a candidate account | | `POST` | `/api/auth/login` | ❌ No | Authenticate user & receive JWT token | | `POST` | `/api/auth/refresh` | ❌ No | Rotate a refresh token and receive a new session | | `POST` | `/api/auth/logout` | ❌ No | Revoke a refresh token | +| `GET` | `/api/auth/oauth/{provider}` | ❌ No | Start Google, GitHub, or LinkedIn sign-in | +| `GET` | `/api/auth/oauth/{provider}/callback` | ❌ No | Provider callback and one-time SPA handoff | +| `POST` | `/api/auth/exchange` | ❌ No | Exchange the short-lived handoff code for a session | | `GET` | `/api/auth/me` | ✅ Yes (Bearer) | Get current authenticated user profile | | `GET` | `/api/problems` | ❌ No | List all LLD problems (with filter queries) | | `GET` | `/api/problems/{id}` | ❌ No | Retrieve complete problem statement & files | @@ -29,15 +33,16 @@ High-performance, modular Low-Level Design (LLD) & Machine Coding Backend Engine | `PUT` | `/api/problems/{id}/workspace` | ✅ Yes | Autosave caller's private code draft | | `DELETE` | `/api/problems/{id}/workspace?language=java` | ✅ Yes | Discard caller's private code draft | | `POST` | `/api/admin/problems` | ✅ Admin | Create custom LLD problem statement | -| `PUT` | `/api/admin/problems/{id}` | ✅ Admin | Update existing problem statement | +| `PUT` | `/api/admin/problems/{id}` | ✅ Admin | Compare-and-swap update of an existing problem statement | | `DELETE`| `/api/admin/problems/{id}` | ✅ Admin | Delete problem statement | | `POST` | `/api/admin/problems/reset/defaults` | ✅ Admin | Reset all problems to default seed catalog | -| `POST` | `/api/execute/run` | ❌ No | Run multi-file project in sandbox | -| `POST` | `/api/execute/submit` | ✅ Yes | Submit solution for scoring and test evaluation | -| `GET` | `/api/submissions` | ✅ Yes | List caller submission history (admins can filter all) | -| `GET` | `/api/submissions/{id}` | ✅ Yes | Retrieve a single owned submission | +| `POST` | `/api/execute/run` | ✅ Yes | Enqueue an owned development code run and return an execution ID | +| `POST` | `/api/execute/submit` | ✅ Yes | Enqueue an owned submission job | +| `GET` | `/api/executions/{id}` | ✅ Owner/Admin | Poll owned execution state and terminal result | +| `GET` | `/api/submissions` | ✅ Yes | List a bounded cursor page of lightweight submission summaries | +| `GET` | `/api/submissions/{id}` | ✅ Owner/Admin | Retrieve one owner-scoped full submission | | `GET` | `/api/users/me/progress` | ✅ Yes | Get derived solved and submission counts | -| `POST` | `/api/ai/chat` | ❌ No | AI Interviewer assistant (hints, review, examples) | +| `POST` | `/api/ai/chat` | ✅ Yes | AI Interviewer assistant (hints and code review) | --- @@ -76,10 +81,18 @@ Check if the Go backend engine is live and operational. "success": true, "data": { "status": "healthy", - "service": "InterviewHub Go Engine", - "version": "1.0.0" - }, - "message": "InterviewHub LLD Backend is running smoothly" + "service": "JudgeMyCode API", + "version": "1.0.0", + "queue": { + "enabled": true, + "queueDepth": 0, + "queueCapacity": 32, + "queued": 0, + "running": 0, + "completed": 0, + "failed": 0 + } + } } ``` @@ -152,7 +165,15 @@ Authenticate with email and password to receive a JWT session token. ### `POST /api/auth/refresh` -Refresh tokens are opaque credentials. Each successful refresh revokes the supplied token and returns a replacement, so callers must persist the returned `refreshToken`. +Refresh tokens are opaque, single-use credentials. Each successful refresh +revokes the supplied token and returns a replacement, so callers must persist +the returned `refreshToken`. Replacement tokens keep the family's original +absolute expiry; refresh does not create an indefinitely sliding session. +Rotation and logout serialize on the family, replay revokes every active +generation, and the bounded family requires a new login after 4,096 rotations. +Refresh and logout each enforce per-source and per-opaque-credential admission +limits and can return `429 Too Many Requests`. Bearer values are SHA-256 hashed +before they become process-retained limiter keys. ```json { "refreshToken": "opaque-refresh-token" } @@ -160,12 +181,30 @@ Refresh tokens are opaque credentials. Each successful refresh revokes the suppl ### `POST /api/auth/logout` -Revokes the supplied refresh token and returns `204 No Content`. +Revokes the supplied refresh token and returns `204 No Content`. The token is +required and must be no more than 512 bytes. Repeated requests are admitted +under the same source and opaque-credential limits described above; a request +over either budget returns `429 Too Many Requests` without querying storage. ```json { "refreshToken": "opaque-refresh-token" } ``` +### OAuth browser flow + +`GET /api/auth/oauth/{provider}` writes an HttpOnly, SameSite state cookie and +redirects to `google`, `github`, or `linkedin`. The callback validates and +consumes that one-time state, binds the account by the provider's immutable +subject, and redirects the SPA with a short-lived handoff code in the URL +fragment. Access and refresh tokens are never placed in a redirect URL. + +OAuth source admission, outbound concurrency, authorization-code/state sizes, +provider response sizes, and provider HTTP time are bounded. An upgraded +installation with legacy email-only social accounts must bind verified +subjects using `/app/oauth-bind` from the backend image (or +`go run ./cmd/oauth-bind` from source); the API refuses to start that provider until +the migration is complete. + --- ### `GET /api/auth/me` @@ -233,7 +272,9 @@ Retrieve list of all Low-Level Design problems with optional filtering. "expectedOutput": "Allocated Spot: F1-S1 | Total Fee: $20.0", "status": "idle" } - ] + ], + "createdAt": "2026-08-01T09:00:00Z", + "updatedAt": "2026-08-07T12:34:56.123456Z" } ] } @@ -242,14 +283,38 @@ Retrieve list of all Low-Level Design problems with optional filtering. --- ### `GET /api/problems/{id}` -Get full problem specification, starter files across 5 languages, and evaluation test cases. +Get the problem specification, starter files across five languages, and public +evaluation cases. Hidden cases are server-owned and are never returned by a +catalog endpoint. -- **Path Parameters**: `id` - Problem identifier (e.g. `parking-lot`, `bookmyshow`, `rate-limiter`, `lru-cache`, `vending-machine`, `snake-and-ladder`, `splitwise`, `chess-game`) +- **Path Parameters**: `id` - Problem identifier (for example `parking-lot`, `bookmyshow`, `rate-limiter`, or `lru-cache`) - **Response `200 OK`**: Complete Problem object with all metadata and starter file trees. +### Workspace draft endpoints + +`GET /api/problems/{id}/workspace?language=go` returns the authenticated +caller's draft. A missing draft is represented by an empty workspace with +revision `0`. + +`PUT /api/problems/{id}/workspace` is an optimistic compare-and-swap write: + +```json +{ + "language": "go", + "files": [ ... ], + "activeFileId": "main", + "revision": 3 +} +``` + +Use revision `0` to create a draft, then send the exact revision returned by +the previous read/write. A stale revision returns `409 Conflict`; no newer +server draft is overwritten. Drafts are private to `(user, problem, +language)` and bounded by node count and source size. + --- -### `POST /api/problems` +### `POST /api/admin/problems` Create a new problem statement. - **Request Body**: Full `Problem` JSON model. @@ -257,18 +322,32 @@ Create a new problem statement. --- -### `PUT /api/problems/{id}` +### `PUT /api/admin/problems/{id}` Update an existing problem statement. - **Path Parameters**: `id` - Problem ID -- **Request Body**: Updated `Problem` fields. -- **Response `200 OK`**: Returns updated problem. +- **Request Body**: The complete public `Problem` document, including the exact + `updatedAt` value returned by the last catalog read or successful update. +- **Response `200 OK`**: Returns the updated problem and its new `updatedAt` + revision. +- **Response `409 Conflict`**: The persisted problem changed after the editor + loaded it. No statement, boilerplate, storage reference, public test, or + hidden test is overwritten; reload the problem before retrying. + +`updatedAt` is an optimistic-concurrency token, not a client-selected +timestamp. The API preserves server-owned hidden tests and performs the +guarded statement update plus every satellite-table replacement in one +transaction. --- -### `DELETE /api/problems/{id}` +### `DELETE /api/admin/problems/{id}` Delete a problem statement from catalog. +If a submission or candidate workspace references the problem, deletion +returns `409 Conflict`. This protects history and prevents an administrative +catalog action from cascading candidate-owned drafts. + - **Response `200 OK`**: ```json { @@ -280,9 +359,16 @@ Delete a problem statement from catalog. --- -### `POST /api/problems/reset/defaults` +### `POST /api/admin/problems/reset/defaults` Reset all problems back to the default built-in seed catalog. +The operation updates retained seed IDs in place. It deletes an obsolete +problem only when neither submission history nor a candidate workspace +references it; otherwise it returns `409 Conflict` without partial catalog +changes. Reset and seed synchronization are atomic catalog mutations and are +serialized with admin edits. Other storage failures return +`503 Service Unavailable`. + - **Response `200 OK`**: ```json { @@ -294,10 +380,19 @@ Reset all problems back to the default built-in seed catalog. --- -## 4. Code Execution & Sandboxing +## 4. Asynchronous code execution + +> **Development-only runner:** the current adapter invokes candidate-language processes on the API host. Set `EXECUTION_ENABLED=false` outside trusted local development. It must not be exposed to untrusted traffic until a dedicated, network-disabled judge is implemented. ### `POST /api/execute/run` -Compile and execute a multi-file project inside an isolated sandbox. +Validate and enqueue a multi-file development run. The request supports three +mutually exclusive modes: + +- omit `testCaseId` and `runTests` to execute once with `customInput`, without assertions; +- provide `testCaseId` to evaluate one public catalog test; +- set `runTests` to `true` to evaluate all public catalog tests. + +- **Headers**: `Authorization: Bearer ` - **Request Body**: ```json @@ -314,99 +409,182 @@ Compile and execute a multi-file project inside an isolated sandbox. } ], "customInput": "optional stdin input", - "testCases": [] + "testCaseId": "optional-public-test-id", + "runTests": false } ``` +- `testCaseId` and `runTests: true` cannot be combined. +- Caller-supplied `testCases` are discarded; assertions are catalog-owned. +- A catalog test runs the candidate entrypoint with the test input on stdin and + compares normalized stdout with the expected output. +- `concurrentWorkers` runs isolated entrypoint processes with worker metadata; + it is not a shared-state/thread-safety harness. - **Supported Languages**: `"java"`, `"python"`, `"go"`, `"cpp"`, `"typescript"`. -- **Response `200 OK`**: +- **Local toolchains**: `javac`/`java`, `python3`, Go, `g++` with C++17 support, + and Node.js 22.12+ respectively. A missing executable produces + `RUNTIME_UNAVAILABLE`; the server never fabricates successful output. +- Direct-run and catalog timeouts remain the candidate runtime budget. The + trusted local runner adds a bounded cold-compilation allowance for Go, Java, + and C++ while retaining the 30-second execution ceiling. +- **Response `202 Accepted`**: ```json { "success": true, "data": { - "success": true, - "status": "SUCCESS", - "stdout": "=== Parking Lot Initialized ===", - "stderr": "", - "durationMs": 42, - "passedTests": 3, - "totalTests": 3, - "testResults": [ - { - "testCaseId": "tc-1", - "name": "Single Vehicle Park & Exit", - "passed": true, - "durationMs": 18, - "expected": "Allocated Spot: F1-S1", - "actual": "Allocated Spot: F1-S1" - } - ] - } + "executionId": "exec_4e5f6a7b", + "status": "QUEUED", + "kind": "run" + }, + "message": "Execution queued" } ``` --- ### `POST /api/execute/submit` -Submit final project code for scoring and persistent candidate grading. +Enqueue final project code for owned, persistent candidate grading. -- **Headers** *(Optional)*: `Authorization: Bearer ` -- **Request Body**: Same as `POST /api/execute/run`. -- **Response `201 Created`**: +- **Headers**: `Authorization: Bearer ` +- **Request Body**: The same source fields as `POST /api/execute/run`, but + `testCaseId` and `runTests` must be omitted. Submit always evaluates every + public and hidden server-owned case and persists the terminal result. +- **Response `202 Accepted`**: ```json { "success": true, "data": { - "id": "sub_4e5f6a7b", - "userId": "usr_7a8b9c1d", - "problemId": "parking-lot", - "problemTitle": "Design a Multi-Floor Parking Lot System", - "language": "go", - "status": "ACCEPTED", - "passedTests": 3, - "totalTests": 3, - "score": 100, - "durationMs": 85, - "testResults": [ ... ], - "consoleOutput": [ - "=== [Go Sandbox Runtime: GO] ===", - "✓ Multi-file project compiled successfully with zero warnings." - ], - "createdAt": "2026-08-03T12:00:00Z" + "executionId": "exec_8a9b0c1d", + "status": "QUEUED", + "kind": "submit" }, - "message": "Code submitted and graded successfully" + "message": "Submission queued for evaluation" } ``` +### `GET /api/executions/{id}` + +Poll an owned execution. Terminal job status is `COMPLETED`, `FAILED`, or +`TIMED_OUT`. Submit jobs include a persisted `submission`; hidden IDs, names, +expected values, actual values, and detailed errors are redacted. A different +candidate receives `404` for an execution they do not own; admins may inspect +all jobs. + +- **Headers**: `Authorization: Bearer ` +- **Response `200 OK`**: + +```json +{ + "success": true, + "data": { + "executionId": "exec_8a9b0c1d", + "kind": "submit", + "status": "COMPLETED", + "result": { + "success": true, + "status": "SUCCESS", + "stdout": "Parking lot ready", + "stderr": "", + "durationMs": 42, + "passedTests": 3, + "totalTests": 3 + }, + "submission": { + "id": "sub_4e5f6a7b", + "evaluationRevision": "sha256:9f...", + "status": "ACCEPTED", + "score": 100 + } + } +} +``` + +The development queue is bounded. Queue saturation or more than four active +jobs for one user returns `429 Too Many Requests` with `Retry-After`. Job state +is process-local and retained temporarily, so it is not a durable production +queue and is lost on API restart. + +The source tree and server-owned tests are deep-copied at queue admission. +Changing the catalog while a job runs cannot change its evaluation; the +persisted `evaluationRevision` identifies the exact snapshot. + --- ## 5. Submission History ### `GET /api/submissions` -List past submission attempts for candidates and review history. +List past submission attempts as a keyset-paginated summary page. Candidates +see only their own rows; admins see all rows and may apply the same problem +filter. Results are ordered by `createdAt DESC, id DESC` so equal timestamps +remain stable across pages. -- **Headers** *(Optional)*: `Authorization: Bearer ` +- **Headers**: `Authorization: Bearer ` - **Query Parameters**: - `problemId`: Filter submissions by specific problem ID (e.g. `parking-lot`) + - `limit`: Page size from `1` to `100`; defaults to `50` + - `cursor`: Opaque `nextCursor` from the preceding page. Cursors are bound to + the caller scope and `problemId`, so they cannot be reused across filters. - **Response `200 OK`**: ```json { "success": true, - "data": [ - { - "id": "sub_4e5f6a7b", - "userId": "usr_7a8b9c1d", - "problemId": "parking-lot", - "problemTitle": "Design a Multi-Floor Parking Lot System", - "language": "go", - "status": "ACCEPTED", - "passedTests": 3, - "totalTests": 3, - "score": 100, - "durationMs": 85, - "createdAt": "2026-08-03T12:00:00Z" - } - ] + "data": { + "items": [ + { + "id": "sub_4e5f6a7b", + "userId": "usr_7a8b9c1d", + "problemId": "parking-lot", + "problemTitle": "Design a Multi-Floor Parking Lot System", + "evaluationRevision": "sha256:9f...", + "language": "go", + "status": "ACCEPTED", + "passedTests": 3, + "totalTests": 3, + "score": 100, + "durationMs": 85, + "createdAt": "2026-08-03T12:00:00Z" + } + ], + "nextCursor": "eyJjcmVhdGVkQXRVbml4TmFubyI6Li4ufQ" + } +} +``` + +List rows deliberately omit `files`, `testResults`, and `consoleOutput`. +PostgreSQL projects their scalar fields from indexed generated columns, applies +owner/problem/cursor predicates in SQL, and fetches at most `limit + 1` rows. +This avoids decoding or transferring every full JSON submission document. + +### `GET /api/submissions/{id}` + +Return the complete submission only when its ID belongs to the caller (or when +the caller is an admin). The owner predicate and ID predicate execute together +through an indexed lookup. A nonexistent ID and another user's ID both return +`404 Not Found`, without loading global or tenant history. + +Detail reads never return stored expected or actual assertion values. +Unknown, removed, or historically hidden test identifiers are renamed to a +generic protected ordinal. Legacy rows without an immutable evaluation revision +protect every test identifier and name. The problem ID/title and evaluation +revision remain historical metadata even if the live catalog later changes. + +### `GET /api/users/me/progress` + +Return submission, acceptance, and distinct solved-problem counts plus the +sorted `solvedProblemIds` array. PostgreSQL computes these values with one +owner-filtered aggregate query over scalar indexed columns; it does not load +submission JSON into the API process. + +```json +{ + "success": true, + "data": { + "solvedCount": 2, + "submissionCount": 6, + "acceptedCount": 3, + "currentStreakDays": 0, + "solvedProblemIds": ["parking-lot", "rate-limiter"] + } } ``` @@ -417,6 +595,14 @@ List past submission attempts for candidates and review history. ### `POST /api/ai/chat` Interact with the AI Staff Interviewer for design reviews, hints, design pattern recommendations, and clarification questions. +- **Headers**: `Authorization: Bearer ` +- **Server configuration**: `AI_API_URL`, `AI_API_KEY`, and `AI_MODEL` must identify an OpenAI-compatible chat-completions provider. `AI_MAX_OUTPUT_TOKENS` defaults to `700` and is capped at `4096`. If provider configuration is absent, the endpoint returns `503 Service Unavailable`. +- Prompts, source excerpts, provider responses, per-user/source request rates, + and concurrent provider calls are bounded. Hidden tests and reference + solutions are not sent to the provider. Staging/production requires an + external distributed quota/spend guard. +- **Response category**: `general` when no code excerpt is supplied; `review` when `codeSnippet` is present. + - **Request Body**: ```json { @@ -431,11 +617,10 @@ Interact with the AI Staff Interviewer for design reviews, hints, design pattern { "success": true, "data": { - "id": "ai_msg_987654", "role": "assistant", "content": "For high-concurrency parking lots, using method-level `synchronized` can create a bottleneck across all floors. Instead, consider:\n1. **Segmented Locking**: Lock only the specific Floor or Spot row.\n2. **Atomic / Concurrent Queues**: Use `ConcurrentLinkedQueue` or `AtomicInteger` per floor capacity.\n3. **Optimistic Locking**: Check and CAS (Compare-And-Swap) spot status.", - "category": "hint", - "timestamp": "12:05 PM" + "category": "review", + "timestamp": "2026-08-07T06:35:12.123456Z" } } ``` @@ -446,27 +631,28 @@ Interact with the AI Staff Interviewer for design reviews, hints, design pattern ### 1. Register User ```bash -curl -X POST http://localhost:8080/api/auth/register \ +curl -X POST http://127.0.0.1:5050/api/auth/register \ -H "Content-Type: application/json" \ -d '{"name":"Alex Rivera","email":"alex@example.com","password":"password123","role":"candidate"}' ``` ### 2. Login ```bash -curl -X POST http://localhost:8080/api/auth/login \ +curl -X POST http://127.0.0.1:5050/api/auth/login \ -H "Content-Type: application/json" \ -d '{"email":"alex@example.com","password":"password123"}' ``` ### 3. List Problems ```bash -curl -X GET "http://localhost:8080/api/problems?difficulty=Medium" +curl -X GET "http://127.0.0.1:5050/api/problems?difficulty=Medium" ``` ### 4. Execute Code ```bash -curl -X POST http://localhost:8080/api/execute/run \ +curl -X POST http://127.0.0.1:5050/api/execute/run \ -H "Content-Type: application/json" \ + -H "Authorization: Bearer $TOKEN" \ -d '{ "problemId": "parking-lot", "language": "go", @@ -483,8 +669,9 @@ curl -X POST http://localhost:8080/api/execute/run \ ### 5. Ask AI Interviewer ```bash -curl -X POST http://localhost:8080/api/ai/chat \ +curl -X POST http://127.0.0.1:5050/api/ai/chat \ -H "Content-Type: application/json" \ + -H "Authorization: Bearer $TOKEN" \ -d '{ "problemId": "parking-lot", "prompt": "Give me a hint for pricing strategy pattern" @@ -496,12 +683,10 @@ curl -X POST http://localhost:8080/api/ai/chat \ ## 🛠️ Running the Go Backend ```bash -cd /Users/tabishmalik/.gemini/antigravity/scratch/interviewhub-backend-go - -# Run server -go run cmd/server/main.go +# From the repository root (recommended full local stack) +./run-local.sh -# Or build binary -go build -o bin/server cmd/server/main.go -./bin/server +# Or from this backend directory after configuring .env.local and PostgreSQL +./scripts/seed-db.sh +go run ./cmd/server ``` diff --git a/interviewhub-backend-go/Dockerfile b/interviewhub-backend-go/Dockerfile index 9c5f6be9..e34e4985 100644 --- a/interviewhub-backend-go/Dockerfile +++ b/interviewhub-backend-go/Dockerfile @@ -1,12 +1,44 @@ -FROM golang:1.25-alpine AS build +# syntax=docker/dockerfile:1.7 + +FROM --platform=$BUILDPLATFORM golang:1.25-alpine AS build +ARG TARGETOS +ARG TARGETARCH + WORKDIR /src -COPY go.mod ./ -RUN go mod download -COPY . . -RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /out/server ./cmd/server +COPY go.mod go.sum ./ +RUN --mount=type=cache,target=/go/pkg/mod \ + go mod download + +COPY cmd ./cmd +COPY internal ./internal +RUN --mount=type=cache,target=/go/pkg/mod \ + --mount=type=cache,target=/root/.cache/go-build \ + CGO_ENABLED=0 GOOS="$TARGETOS" GOARCH="$TARGETARCH" \ + go build -trimpath -ldflags="-s -w" -o /out/server ./cmd/server && \ + CGO_ENABLED=0 GOOS="$TARGETOS" GOARCH="$TARGETARCH" \ + go build -trimpath -ldflags="-s -w" -o /out/seed ./cmd/seed && \ + CGO_ENABLED=0 GOOS="$TARGETOS" GOARCH="$TARGETARCH" \ + go build -trimpath -ldflags="-s -w" -o /out/oauth-bind ./cmd/oauth-bind + +FROM alpine:3.21 +RUN apk add --no-cache ca-certificates tzdata \ + && addgroup -S app \ + && adduser -S -G app -h /app app \ + && mkdir -p /app/temp_sandboxes \ + && chown -R app:app /app -FROM alpine:3.20 WORKDIR /app -COPY --from=build /out/server /app/server +COPY --from=build --chown=app:app /out/server /app/server +COPY --from=build --chown=app:app /out/seed /app/seed +COPY --from=build --chown=app:app /out/oauth-bind /app/oauth-bind + +USER app +ENV APP_ENV=production \ + HOST=0.0.0.0 \ + PORT=5050 \ + EXECUTION_ENABLED=false EXPOSE 5050 -CMD ["/app/server"] +HEALTHCHECK --interval=10s --timeout=3s --start-period=10s --retries=3 \ + CMD wget -q -T 2 -O /dev/null http://127.0.0.1:5050/api/health/ready || exit 1 + +ENTRYPOINT ["/app/server"] diff --git a/interviewhub-backend-go/Dockerfile.worker b/interviewhub-backend-go/Dockerfile.worker deleted file mode 100644 index e664661e..00000000 --- a/interviewhub-backend-go/Dockerfile.worker +++ /dev/null @@ -1,3 +0,0 @@ -FROM alpine:3.20 -WORKDIR /app -CMD ["sh", "-c", "echo 'judge worker placeholder'; sleep infinity"] diff --git a/interviewhub-backend-go/README.md b/interviewhub-backend-go/README.md index bea171c8..14a5e075 100644 --- a/interviewhub-backend-go/README.md +++ b/interviewhub-backend-go/README.md @@ -1,101 +1,150 @@ -# 🚀 InterviewHub / JudgeMyCode Go Backend Engine +# JudgeMyCode Go API -A modular, high-throughput Low-Level Design (LLD) and Machine Coding backend engine written in **Go** (Golang). +The API serves authentication, the LLD problem catalog, private workspaces, asynchronous executions, submissions, progress, and administration. Its dependency direction is: -## Architecture for contributors and AI agents +`router -> handlers -> services -> repository -> PostgreSQL` -Read [AGENTS.md](./AGENTS.md) before changing this service. It defines the layer boundaries, API conventions, authorization rules, and code-execution safety requirements. +Read [AGENTS.md](./AGENTS.md) before changing the service. -The backend follows a deliberately simple dependency direction: +## Local development -`router → handlers → services → store → PostgreSQL` - -All catalog, user, workspace, and submission data is loaded from PostgreSQL at runtime. -Use `./scripts/seed-db.sh` to migrate schema and optionally populate the seed catalog into an empty database. -The former JSON file store is no longer used by the API server (retained only for unit tests). - -### PostgreSQL local setup - -`DATABASE_URL` is **required**. Copy `.env.example` to `.env` / `.env.local` and set: +The supported full-stack command is run from the repository root: ```bash -DATABASE_URL=postgres://judgemycode:local-dev-password@localhost:5432/judgemycode?sslmode=disable +./run-local.sh ``` -Start Postgres: +It starts loopback-only PostgreSQL, applies embedded migrations, explicitly seeds local demo users and the problem catalog, installs frontend dependencies, and starts the API on `http://127.0.0.1:5050` and Vite on `http://127.0.0.1:5173`. Repeating the command rebuilds and restarts both application processes. + +For backend-only work: ```bash docker compose up -d postgres +test -f .env.local || cp .env.example .env.local +./scripts/seed-db.sh # migrations + problem catalog +./scripts/seed-db.sh --users # opt-in local demo accounts +go run ./cmd/server ``` -Migrate + seed catalog (idempotent; safe to re-run): +Go 1.25 or a compatible newer toolchain is required. `DATABASE_URL` and `JWT_SECRET` must be explicitly configured. Local PostgreSQL defaults to: -```bash -./scripts/seed-db.sh # migrate + fill empty tables -./scripts/seed-db.sh --sync-problems # upsert seed catalog by id -./scripts/seed-db.sh --migrate-only # schema only -DATABASE_URL='postgres://...' ./scripts/seed-db.sh --sync-problems +```text +postgres://judgemycode:local-dev-password@127.0.0.1:5432/judgemycode?sslmode=disable ``` -Run the API: +Do not use these credentials outside local development. + +## Checks ```bash -go run ./cmd/server +go test ./... +go test -race ./... +go vet ./... +go build ./cmd/server ./cmd/seed ./cmd/oauth-bind ``` -Or from the repo root: `./run-local.sh` (starts Postgres when Docker is available, seeds, then boots API + Vite). - -Set `APP_ENV=production` only with a non-default 32+ character `JWT_SECRET`, an explicit `DATABASE_URL`, and a narrow `ALLOWED_ORIGINS` list. The application rejects an unsafe production configuration at startup. - -## ✨ Core Highlights -- **Engine Architecture**: Clean architecture with strict separation of concerns (`models`, `store`, `services`, `handlers`, `middleware`). -- **Concurrent Sandbox Execution**: Executes multi-file candidate submissions in isolated runtime processes with timeout guardrails. -- **5 Language Matrix**: Java, Python, Go, C++, and TypeScript. -- **JWT Authentication & RBAC**: Candidate and Admin roles with secure password hashing, token claims, and admin-only catalog writes. -- **Rich LLD Seed Catalog**: Multi-Floor Parking Lot, BookMyShow, Distributed Rate Limiter, LRU Cache, Splitwise, Vending Machine, Chess, and Snake & Ladder. -- **AI Interviewer Assistant**: API endpoint ready; requires a real LLM provider (no mock replies). -- **Candidate Workspaces**: Authenticated, multi-file draft autosaves scoped to a user, problem, and language. -- **Automated Grading Engine**: Real-time test evaluation and score tracking. +The root `make check`, `make test`, and CI workflow run the repository-wide gates. + +## Execution contract + +Authenticated `POST /api/execute/run` and `POST /api/execute/submit` enqueue work and return `202 Accepted` with `executionId`, `status`, and `kind`. Poll the authenticated, owner/admin-scoped `GET /api/executions/{id}` resource until `COMPLETED`, `FAILED`, or `TIMED_OUT`. Submit jobs include a persisted `submission` when terminal; hidden assertions are never returned. + +Run modes are explicit: + +- no selector: execute once with `customInput`, without grading; +- `testCaseId`: evaluate one public, server-owned test; +- `runTests: true`: evaluate every public test; +- submit: reject run selectors, evaluate all public and hidden tests, redact + hidden details, and persist the terminal result. + +Each selected test executes the candidate entrypoint with that test's input on +stdin and compares normalized stdout with the catalog expectation. Caller +`testCases` data is discarded. The local host must provide `python3`, +`javac`/`java`, `g++`, Go, and Node.js 22.12+ to exercise every supported +language; a missing executable is reported honestly as `RUNTIME_UNAVAILABLE`. + +`concurrentWorkers` launches isolated copies of the entrypoint with worker +metadata in environment variables. It is a repeatability/load signal, not a +shared-memory API harness, so the current evaluator must not be presented as +proof of thread-safety inside one application instance. + +The queue is bounded in process for development, admits at most four active +jobs per user, and retains terminal jobs temporarily for polling. There is no +Redis-backed durable queue, cross-replica job state, cancellation endpoint, or +external worker in this repository. + +Each queued job owns a deep copy of its source request and catalog tests. +Submission rows record a SHA-256 `evaluationRevision`, so an administrator +editing the live catalog cannot alter an in-flight grade. History reads remove +all stored expected/actual assertions and fail closed for test IDs that are no +longer public. + +History lists are lightweight keyset pages (50 rows by default, 100 maximum), +filtered by owner and optional problem in PostgreSQL. They project indexed +scalar columns and omit source files and execution output; the full JSON record +is loaded only by the indexed owner-scoped detail route. Progress counts and +solved problem IDs are computed with one SQL aggregate. + +## Draft and session consistency + +Workspaces use monotonically increasing revisions. Create uses revision `0`; +every later `PUT` must send the revision returned by the previous read/write. +A stale write returns `409 Conflict` and never overwrites the newer draft. +Problem deletion and seed reconciliation refuse to remove rows referenced by a +candidate workspace or existing submission. + +Admin problem updates must return the `updatedAt` value loaded with the editor. +PostgreSQL guards the parent update with that exact timestamp and replaces all +starter/test satellites in the same transaction. Create, update, delete, +seed-sync, and reset operations share a catalog advisory lock; concurrent or +pre-reset editor copies fail with `409 Conflict`. + +Refresh tokens are single-use families. Rotation, replay revocation, and +logout share a PostgreSQL advisory family lock, retain the family's original +absolute expiry, and cap a family at 4,096 generations. The HTTP surface also +limits refresh and logout independently by source and opaque credential; raw +bearer values are hashed before entering the process-local limiter. These +admission buckets complement—rather than replace—an ingress/distributed rate +limiter. PostgreSQL generation counts and family revocations query the indexed, +`NOT NULL` `family_id` column directly. Expired refresh-token and +authorization-code retention runs only after successful issuance, in bounded +batches that skip locked rows; maintenance contention or failure therefore +cannot invalidate a newly issued credential. + +OAuth identities are keyed by provider subject, never by mutable email. After +migration 008, enabling a provider makes API startup fail if a legacy social +account lacks this binding. An operator must verify the immutable subject with +the provider and bind it explicitly: -## API resource map - -| Resource | Access | Purpose | -| --- | --- | --- | -| `GET /api/problems` | public | Browse the problem catalog | -| `GET /api/problems/{id}` | public | Load a problem and starter code | -| `GET/PUT/DELETE /api/problems/{id}/workspace` | authenticated | Read, save, or discard a private draft (`language` query required for GET/DELETE) | -| `POST /api/execute/run` | public | Development-only code run | -| `POST /api/execute/submit` | authenticated | Record a graded submission | -| `GET /api/submissions`, `GET /api/submissions/{id}` | authenticated | View only the caller's submissions (admins can view all) | -| `GET /api/users/me/progress` | authenticated | Derived solved and submission counts | -| `/api/admin/problems/*` | admin | Create, update, delete, or reset catalog entries | +```bash +/app/oauth-bind \ + --provider google \ + --subject VERIFIED_PROVIDER_SUBJECT \ + --user-email person@example.com \ + --confirm-user-id usr_exact_database_id +``` -## Launch gate: untrusted code execution +The confirmation ID is intentionally required as a typo guard. Do not infer a +subject from email. `/app/oauth-bind` is included in the production backend +image; use `go run ./cmd/oauth-bind` when operating directly from source. -The current execution service invokes language runtimes on the application host, which is acceptable only while developing locally. **Do not deploy it as a public code runner.** Move execution to short-lived, network-disabled containers or a dedicated judge service and enforce CPU, memory, process, file-size, disk, and wall-time limits. Keep hidden test cases server-owned; never accept them as authoritative client input. +AI interview feedback is optional. Set `AI_API_URL`, `AI_API_KEY`, and `AI_MODEL` for an OpenAI-compatible chat-completions endpoint; `AI_REQUEST_TIMEOUT` defaults to `20s` and `AI_MAX_OUTPUT_TOKENS` to `700` (maximum `4096`). Provider responses, concurrent calls, prompts, and source snippets are bounded. If configuration is absent, the endpoint fails closed with a configuration/unavailable error and never returns mock feedback. Outside local development, `AI_EXTERNAL_GUARD=true` is required to acknowledge an upstream distributed quota and spend budget. ---- +## Production limitations -## 📖 API Documentation -For complete endpoint specifications, JSON request/response payloads, and curl examples, see: -➡️ **[API_DOCUMENTATION.md](./API_DOCUMENTATION.md)** +The local execution engine invokes language runtimes on the API host. It is not an isolation boundary. Set `EXECUTION_ENABLED=false` in every production-like deployment. A public judge requires dedicated short-lived, network-disabled containers or VMs with resource controls and an independently scalable durable queue. ---- +Production also requires: -## 🚀 Quick Start +- a unique high-entropy JWT secret supplied by a secret manager; +- TLS PostgreSQL credentials and narrowly scoped CORS origins; +- explicit `TRUSTED_PROXY_CIDRS` when—and only when—traffic arrives through a + known reverse proxy, so forwarded client addresses cannot be spoofed; +- migrations run as a controlled deployment job; +- no demo-user seeding; +- metrics access control, distributed rate limiting, audit logs, backups, and + tested restore procedures. -### Prerequisites -- Go 1.21+ installed +The backend Docker image is non-root and multi-architecture aware. It includes `/app/seed` for deployment jobs; run `/app/seed --migrate-only` before rolling out the API. Readiness then verifies every embedded migration and its checksum. The image deliberately defaults `EXECUTION_ENABLED=false` and contains no candidate-language toolchains. -### Run Development Server -```bash -cd /Users/tabishmalik/.gemini/antigravity/scratch/interviewhub-backend-go -go run cmd/server/main.go -``` - -The server starts on `http://localhost:8080`. - -### Health Check -```bash -curl http://localhost:8080/api/health -``` +See [API_DOCUMENTATION.md](./API_DOCUMENTATION.md) and [docs/production-architecture.md](./docs/production-architecture.md). diff --git a/interviewhub-backend-go/cmd/oauth-bind/main.go b/interviewhub-backend-go/cmd/oauth-bind/main.go new file mode 100644 index 00000000..a9c6b81d --- /dev/null +++ b/interviewhub-backend-go/cmd/oauth-bind/main.go @@ -0,0 +1,79 @@ +// Command oauth-bind performs the explicit, operator-verified migration of a +// legacy social account to a provider's stable subject identifier. +package main + +import ( + "context" + "flag" + "fmt" + "log" + "strings" + "time" + + "github.com/interviewhub/backend/internal/config" + "github.com/interviewhub/backend/internal/store" +) + +func main() { + provider := flag.String("provider", "", "OAuth provider: google, github, or linkedin") + subject := flag.String("subject", "", "immutable subject identifier verified with the provider") + userEmail := flag.String("user-email", "", "existing JudgeMyCode account email") + confirmUserID := flag.String("confirm-user-id", "", "exact existing user ID (second-factor typo guard)") + providerEmail := flag.String("provider-email", "", "provider's current verified email (defaults to user-email)") + timeout := flag.Duration("timeout", 30*time.Second, "operation timeout") + flag.Parse() + + cleanProvider := strings.ToLower(strings.TrimSpace(*provider)) + if cleanProvider != "google" && cleanProvider != "github" && cleanProvider != "linkedin" { + log.Fatal("--provider must be google, github, or linkedin") + } + cleanSubject := strings.TrimSpace(*subject) + cleanEmail := strings.ToLower(strings.TrimSpace(*userEmail)) + if cleanSubject == "" || len(cleanSubject) > 512 || cleanEmail == "" || strings.TrimSpace(*confirmUserID) == "" { + log.Fatal("--subject, --user-email, and --confirm-user-id are required (subject max 512 bytes)") + } + verifiedEmail := strings.ToLower(strings.TrimSpace(*providerEmail)) + if verifiedEmail == "" { + verifiedEmail = cleanEmail + } + + cfg := config.LoadConfig() + if strings.TrimSpace(cfg.DatabaseURL) == "" { + log.Fatal("DATABASE_URL is required") + } + ctx, cancel := context.WithTimeout(context.Background(), *timeout) + defer cancel() + + postgres, err := store.OpenPostgres(ctx, cfg.DatabaseURL) + if err != nil { + log.Fatalf("connect: %v", err) + } + defer postgres.Close() + if err := postgres.ValidateMigrations(ctx); err != nil { + log.Fatalf("validate migrations: %v", err) + } + + user, found, err := postgres.LookupUserByEmail(cleanEmail) + if err != nil { + log.Fatalf("lookup account: %v", err) + } + if !found { + log.Fatal("account was not found") + } + if user.ID != strings.TrimSpace(*confirmUserID) { + log.Fatal("--confirm-user-id does not match the account resolved by --user-email") + } + if !strings.EqualFold(user.AuthProvider, cleanProvider) || user.PasswordHash != "" { + log.Fatal("account is not an unbound legacy OAuth-only account for this provider") + } + if _, found, err := postgres.LookupOAuthIdentity(cleanProvider, cleanSubject); err != nil { + log.Fatalf("check provider identity: %v", err) + } else if found { + log.Fatal("provider subject is already linked") + } + if err := postgres.LinkOAuthIdentity(cleanProvider, cleanSubject, user.ID, verifiedEmail); err != nil { + log.Fatalf("link identity: %v", err) + } + + fmt.Printf("OK: bound %s account %s to the verified provider subject\n", cleanProvider, user.ID) +} diff --git a/interviewhub-backend-go/cmd/seed/main.go b/interviewhub-backend-go/cmd/seed/main.go index c57eaf7b..53ddf567 100644 --- a/interviewhub-backend-go/cmd/seed/main.go +++ b/interviewhub-backend-go/cmd/seed/main.go @@ -5,7 +5,6 @@ import ( "flag" "fmt" "log" - "os" "time" "github.com/interviewhub/backend/internal/config" @@ -19,7 +18,7 @@ import ( // // Examples: // -// DATABASE_URL='postgres://judgemycode:local-dev-password@localhost:5432/judgemycode?sslmode=disable' go run ./cmd/seed +// DATABASE_URL='postgres://judgemycode:local-dev-password@localhost:5432/judgemycode?sslmode=disable' go run ./cmd/seed --users // go run ./cmd/seed --sync-problems // go run ./cmd/seed --reset-problems # destructive, local/dev only // go run ./cmd/seed --force-users @@ -28,13 +27,21 @@ func main() { migrateOnly := flag.Bool("migrate-only", false, "apply SQL migrations and exit without seeding") skipMigrate := flag.Bool("skip-migrate", false, "skip SQL migrations") - seedUsers := flag.Bool("users", true, "seed demo users when the users table is empty") + seedUsers := flag.Bool("users", false, "seed local demo users when the users table is empty (opt-in)") forceUsers := flag.Bool("force-users", false, "upsert demo admin/candidate users even if users already exist") seedProblems := flag.Bool("problems", true, "seed problem catalog when the problems table is empty") syncProblems := flag.Bool("sync-problems", false, "upsert all seed problems by id without deleting other rows") resetProblems := flag.Bool("reset-problems", false, "DELETE all problems and reload the seed catalog (destructive)") timeout := flag.Duration("timeout", 60*time.Second, "overall operation timeout") flag.Parse() + switch cfg.AppEnv { + case "development", "test", "staging", "production": + default: + log.Fatalf("APP_ENV must be development, test, staging, or production; got %q", cfg.AppEnv) + } + if (cfg.IsProduction() || cfg.AppEnv == "staging") && (*seedUsers || *forceUsers || *resetProblems) { + log.Fatal("demo users and destructive problem resets are disabled in staging/production") + } if cfg.DatabaseURL == "" { log.Fatal("DATABASE_URL is required. Example:\n DATABASE_URL='postgres://judgemycode:local-dev-password@localhost:5432/judgemycode?sslmode=disable' go run ./cmd/seed") @@ -73,5 +80,4 @@ func main() { } fmt.Printf("OK: %s\n", report.String()) - os.Exit(0) } diff --git a/interviewhub-backend-go/cmd/server/main.go b/interviewhub-backend-go/cmd/server/main.go index 5ec1339d..60aaf499 100644 --- a/interviewhub-backend-go/cmd/server/main.go +++ b/interviewhub-backend-go/cmd/server/main.go @@ -2,167 +2,91 @@ package main import ( "context" + "errors" "fmt" "log" + "net" "net/http" "os" "os/signal" + "strconv" "syscall" "time" - "github.com/go-chi/chi/v5" - chimiddleware "github.com/go-chi/chi/v5/middleware" - "github.com/go-chi/cors" + "github.com/interviewhub/backend/internal/app" "github.com/interviewhub/backend/internal/config" - "github.com/interviewhub/backend/internal/handlers" - "github.com/interviewhub/backend/internal/services" - "github.com/interviewhub/backend/internal/store" +) + +const ( + startupTimeout = 15 * time.Second + shutdownTimeout = 10 * time.Second ) func main() { + if err := run(); err != nil { + log.Printf("server stopped with error: %v", err) + os.Exit(1) + } +} + +func run() error { cfg := config.LoadConfig() if err := cfg.Validate(); err != nil { - log.Fatal(err) + return fmt.Errorf("invalid configuration: %w", err) } - // Persistence is PostgreSQL-only. Catalog and user data live in the DB; - // use ./scripts/seed-db.sh to populate an empty database. - postgresStore, err := store.NewPostgres(context.Background(), cfg) + startupContext, cancelStartup := context.WithTimeout(context.Background(), startupTimeout) + application, err := app.New(startupContext, cfg) + cancelStartup() if err != nil { - log.Fatalf("PostgreSQL initialization failed: %v", err) + return fmt.Errorf("initialize application: %w", err) } - defer postgresStore.Close() - repository := postgresStore - log.Println("Using PostgreSQL persistence") - - authService := services.NewAuthService(repository, cfg) - oauthService := services.NewOAuthService(repository, authService, cfg) - problemService := services.NewProblemService(repository) - executionService := services.NewExecutionService(repository, cfg) - submissionService := services.NewSubmissionService(repository, executionService) - aiService := services.NewAIService(repository) - userService := services.NewUserService(repository) - workspaceService := services.NewWorkspaceService(repository) - - // 2. Initialize Handlers and Custom Middleware - apiHandlers := handlers.NewHandlers(authService, oauthService, problemService, executionService, submissionService, aiService, userService, workspaceService, cfg) - authMid := handlers.NewMiddleware(cfg, authService) - - // 3. Router Setup - r := chi.NewRouter() - - // Standard Middlewares - r.Use(chimiddleware.RequestID) - r.Use(chimiddleware.RealIP) - r.Use(chimiddleware.Logger) - r.Use(chimiddleware.Recoverer) - r.Use(handlers.RequestLoggingMiddleware()) - r.Use(handlers.LimitRequestBody(cfg.MaxRequestBytes)) - - // CORS Setup - r.Use(cors.Handler(cors.Options{ - AllowedOrigins: cfg.AllowedOrigins, - AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, - AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"}, - ExposedHeaders: []string{"Link"}, - AllowCredentials: true, - MaxAge: 300, - })) - - // 4. API Routes - r.Route("/api", func(api chi.Router) { - api.Get("/health", apiHandlers.HealthCheck) - api.Get("/health/ready", apiHandlers.Readiness) - api.Get("/metrics", apiHandlers.Metrics) - - // Auth Routes - api.Route("/auth", func(auth chi.Router) { - auth.Post("/register", apiHandlers.Register) - auth.Post("/login", apiHandlers.Login) - auth.Post("/refresh", apiHandlers.Refresh) - auth.Post("/logout", apiHandlers.Logout) - auth.Post("/exchange", apiHandlers.ExchangeAuthCode) - auth.With(authMid.RequireAuth).Get("/me", apiHandlers.GetMe) - - // OAuth 2.0 / OIDC Routes (Google, GitHub, LinkedIn) - auth.Get("/oauth/{provider}", apiHandlers.InitiateOAuth) - auth.Get("/oauth/{provider}/callback", apiHandlers.OAuthCallback) - auth.Post("/oauth/{provider}/token", apiHandlers.OAuthTokenExchange) - }) - api.With(authMid.RequireAuth).Get("/users/me/progress", apiHandlers.GetMyProgress) - - // Problems Routes - api.Route("/problems", func(p chi.Router) { - p.Get("/", apiHandlers.ListProblems) - p.Get("/{id}", apiHandlers.GetProblem) - p.With(authMid.RequireAuth).Get("/{id}/workspace", apiHandlers.GetWorkspace) - p.With(authMid.RequireAuth).Put("/{id}/workspace", apiHandlers.SaveWorkspace) - p.With(authMid.RequireAuth).Delete("/{id}/workspace", apiHandlers.DeleteWorkspace) - }) - - api.Route("/admin/problems", func(p chi.Router) { - p.Use(authMid.RequireAuth, authMid.RequireAdmin) - p.Post("/", apiHandlers.CreateProblem) - p.Put("/{id}", apiHandlers.UpdateProblem) - p.Delete("/{id}", apiHandlers.DeleteProblem) - p.Post("/reset/defaults", apiHandlers.ResetDefaultProblems) - }) - - // Code Execution & Sandboxing Routes - api.Route("/execute", func(exec chi.Router) { - exec.Post("/run", apiHandlers.RunCode) - exec.With(authMid.RequireAuth).Post("/submit", apiHandlers.SubmitCode) - }) - - api.Route("/executions", func(exec chi.Router) { - exec.Get("/{id}", apiHandlers.GetExecution) - }) - - // Submission History Routes - api.Route("/submissions", func(sub chi.Router) { - sub.With(authMid.RequireAuth).Get("/", apiHandlers.ListSubmissions) - sub.With(authMid.RequireAuth).Get("/{id}", apiHandlers.GetSubmission) - }) - - // AI Interviewer Assistant Routes - api.Route("/ai", func(ai chi.Router) { - ai.Post("/chat", apiHandlers.AIChat) - }) - }) + defer func() { + if closeErr := application.Close(); closeErr != nil { + log.Printf("close application resources: %v", closeErr) + } + }() - // 5. Start Server with Graceful Shutdown + address := net.JoinHostPort(cfg.Host, strconv.Itoa(cfg.Port)) server := &http.Server{ - Addr: fmt.Sprintf(":%d", cfg.Port), - Handler: r, - ReadTimeout: 15 * time.Second, - WriteTimeout: 15 * time.Second, - IdleTimeout: 60 * time.Second, + Addr: address, + Handler: application.Handler, + ReadHeaderTimeout: 5 * time.Second, + ReadTimeout: 15 * time.Second, + WriteTimeout: 35 * time.Second, + IdleTimeout: 60 * time.Second, + MaxHeaderBytes: 1 << 20, } + serverErrors := make(chan error, 1) go func() { - fmt.Println("==========================================================") - fmt.Printf("🚀 InterviewHub High-Performance Go Backend Engine Running\n") - fmt.Printf("⚡ Port: %d\n", cfg.Port) - fmt.Printf("🔗 API URL: http://localhost:%d/api\n", cfg.Port) - fmt.Printf("📊 Health: http://localhost:%d/api/health\n", cfg.Port) - fmt.Println("==========================================================") - - if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { - log.Fatalf("Server error: %v", err) - } + log.Printf("JudgeMyCode API listening on http://%s/api", address) + serverErrors <- server.ListenAndServe() }() - // Wait for interrupt signal - quit := make(chan os.Signal, 1) - signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) - <-quit + signalContext, stopSignals := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stopSignals() + + select { + case err := <-serverErrors: + if errors.Is(err, http.ErrServerClosed) { + return nil + } + return fmt.Errorf("serve HTTP: %w", err) + case <-signalContext.Done(): + log.Printf("shutdown signal received") + } - log.Println("Shutting down Go backend server gracefully...") - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() + shutdownContext, cancelShutdown := context.WithTimeout(context.Background(), shutdownTimeout) + defer cancelShutdown() + if err := server.Shutdown(shutdownContext); err != nil { + _ = server.Close() + return fmt.Errorf("graceful HTTP shutdown: %w", err) + } - if err := server.Shutdown(ctx); err != nil { - log.Fatalf("Server forced to shutdown: %v", err) + if err := <-serverErrors; err != nil && !errors.Is(err, http.ErrServerClosed) { + return fmt.Errorf("serve HTTP during shutdown: %w", err) } - log.Println("Go backend stopped cleanly.") + log.Printf("JudgeMyCode API stopped cleanly") + return nil } diff --git a/interviewhub-backend-go/docker-compose.yml b/interviewhub-backend-go/docker-compose.yml index bccefcdb..5d9f1183 100644 --- a/interviewhub-backend-go/docker-compose.yml +++ b/interviewhub-backend-go/docker-compose.yml @@ -1,62 +1,22 @@ services: postgres: image: postgres:17-alpine + restart: unless-stopped environment: - POSTGRES_DB: judgemycode - POSTGRES_USER: judgemycode - POSTGRES_PASSWORD: local-dev-password + POSTGRES_DB: ${POSTGRES_DB:-judgemycode} + POSTGRES_USER: ${POSTGRES_USER:-judgemycode} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-local-dev-password} ports: - - "5432:5432" + - "127.0.0.1:${POSTGRES_PORT:-5432}:5432" volumes: - postgres_data:/var/lib/postgresql/data healthcheck: - test: ["CMD-SHELL", "pg_isready -U judgemycode -d judgemycode"] - interval: 5s - timeout: 5s - retries: 10 - - redis: - image: redis:7-alpine - ports: - - "6379:6379" - healthcheck: - test: ["CMD", "redis-cli", "ping"] + test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"] interval: 5s timeout: 3s - retries: 10 - - api: - build: - context: . - dockerfile: Dockerfile - environment: - APP_ENV: development - PORT: 5050 - JWT_SECRET: local-dev-jwt-secret-please-change-me - DATABASE_URL: postgres://judgemycode:local-dev-password@postgres:5432/judgemycode?sslmode=disable - REDIS_URL: redis://redis:6379/0 - ALLOWED_ORIGINS: http://localhost:5173,http://localhost:3000 - depends_on: - postgres: - condition: service_healthy - redis: - condition: service_healthy - ports: - - "5050:5050" - - worker: - build: - context: . - dockerfile: Dockerfile.worker - environment: - APP_ENV: development - DATABASE_URL: postgres://judgemycode:local-dev-password@postgres:5432/judgemycode?sslmode=disable - REDIS_URL: redis://redis:6379/0 - depends_on: - postgres: - condition: service_healthy - redis: - condition: service_healthy + retries: 12 + start_period: 5s + stop_grace_period: 15s volumes: postgres_data: diff --git a/interviewhub-backend-go/docs/production-architecture.md b/interviewhub-backend-go/docs/production-architecture.md index 9e7b152f..143a6f7b 100644 --- a/interviewhub-backend-go/docs/production-architecture.md +++ b/interviewhub-backend-go/docs/production-architecture.md @@ -1,51 +1,84 @@ -# Production architecture and deployment contract - -## Deployment shape -- React frontend -> Go API -> PostgreSQL -- Redis for queue coordination and shared execution state -- Judge workers run in isolated containers in production - -## Environment contract -- `DATABASE_URL`: PostgreSQL connection string -- `REDIS_URL`: Redis connection string -- `JWT_SECRET`: server signing secret (32+ chars in production) -- `ALLOWED_ORIGINS`: comma-separated list of allowed frontend origins -- `EXECUTION_RUNNER`: `local` or `docker` -- `EXECUTION_DOCKER_ENABLED`: enable containerized judge execution -- `EXECUTION_TIMEOUT_SEC`, `EXECUTION_MAX_FILE_COUNT`, `EXECUTION_MAX_OUTPUT_BYTES`, `EXECUTION_MAX_MEMORY_MB`, `EXECUTION_MAX_PROCESSES`, `EXECUTION_CPU_CORES` - -## Data model highlights - -### Core tables (used by the live API today) -| Table | Purpose | -| --- | --- | -| `users` | Candidates and admins (local + OAuth) | -| `refresh_tokens` | Hashed refresh sessions (rotation + family reuse detection) | -| `auth_codes` | One-time OAuth → SPA exchange codes (tokens never in URLs) | -| `problems` | Statement: title, description, functional/non-functional requirements, APIs | -| `problem_boilerplates` | Per-language starter/boilerplate file trees (+ optional `storage_ref`) | -| `problem_test_cases` | API tests categorized as functionality / concurrency / performance / … | -| `workspaces` | Per-user/problem/language draft files | -| `submissions` | Graded attempts (JSONB payload + FK indexes) | - -### Forward-looking tables (schema present, not yet wired in store) -| Table | Purpose | -| --- | --- | -| `user_preferences` | Theme / notification settings | -| `problem_versions` | Optional version snapshots of tests | -| `executions` | Async run/submit job tracking | -| `audit_logs` | Admin/catalog mutation audit trail | - -AI chat is ephemeral (not persisted). - -Populate any environment with the same tool: - -```bash -DATABASE_URL='postgres://...' ./scripts/seed-db.sh -DATABASE_URL='postgres://...' ./scripts/seed-db.sh --sync-problems +# Production architecture contract + +## What exists today + +```text +React SPA -> Go API -> PostgreSQL + \ + -> bounded in-process development execution queue +``` + +PostgreSQL stores users, provider-subject OAuth identities, refresh sessions, +OAuth handoff codes, problems, revisioned workspaces, and submissions with an +immutable evaluation revision. Executions are asynchronous, but queue/job +state is process-local. The Compose file intentionally provisions only local +PostgreSQL; the former Redis and sleeping worker placeholders were removed. + +Runtime repository calls have a five-second deadline, including pool waits and +transactions. Refresh rotation/logout are serialized by family, family-wide +counts and revocations use the indexed `refresh_tokens.family_id` column, and +both HTTP operations are bounded independently by source and hashed opaque +credential. Workspace writes use compare-and-swap revisions. Applied SQL files +are checksum-verified and must never be edited; schema evolution is append-only. + +Submission history uses an indexed read model separate from its immutable JSON +detail document. List calls apply owner, optional problem, and `(created_at,id)` +keyset predicates in PostgreSQL, cap pages at 100 rows, and project only scalar +summary columns. Detail calls combine owner and ID in one indexed lookup, while +progress is one SQL aggregate. This keeps foreign/nonexistent probes and large +tenant histories bounded without weakening historical test redaction. + +The development evaluator is an entrypoint/stdin/stdout contract. A catalog +case can request multiple isolated processes, but it does not yet invoke typed +problem APIs or test shared state inside one candidate process. A production +judge needs language-specific harness generation before concurrency results can +represent true thread-safety verification. + +## Mandatory production gate + +The current execution adapter launches candidate code on the API host. It is suitable only for trusted local development. Every production-like environment must set: + +```text +EXECUTION_ENABLED=false +``` + +Do not expose run or submit to untrusted traffic until a separate judge plane exists with: + +- immutable per-language images and no runtime package downloads; +- no network, no host mounts, a read-only root, and an isolated scratch filesystem; +- CPU, memory, PID, file-count, output-size, disk, and wall-time enforcement; +- bounded admission, per-user quotas, durable queueing, retries, idempotency, and cancellation; +- authenticated ownership checks and redaction of hidden tests; +- full process-tree termination and sandbox cleanup. + +For developer usability, compiled-language catalog cases receive a bounded +cold-compilation allowance in the local host runner. This does not weaken the +production boundary: that runner remains forbidden outside local development, +and a future isolated judge must enforce independent compile and run budgets. + +## Deployment responsibilities + +- Inject `DATABASE_URL`, a high-entropy `JWT_SECRET`, optional `AI_API_KEY`, OAuth credentials, and narrow `ALLOWED_ORIGINS` through a secret/configuration manager. Configure `AI_API_URL`, `AI_MODEL`, `AI_REQUEST_TIMEOUT`, and `AI_MAX_OUTPUT_TOKENS` only when AI feedback is enabled. `AI_EXTERNAL_GUARD=true` outside development confirms a distributed account quota and spend budget exists upstream. +- Leave `TRUSTED_PROXY_CIDRS` empty for direct traffic. When a reverse proxy is + present, list only its exact networks; forwarded addresses from any other + peer are ignored. +- Use TLS for public traffic and PostgreSQL. Do not use local Compose credentials. +- Run `/app/seed --migrate-only` from the backend image as a deployment job before rolling out API replicas. Demo users are opt-in local data and forbidden in production. +- Before enabling OAuth on an upgraded database, use `/app/oauth-bind` from + the backend image (or `go run ./cmd/oauth-bind` from source) to bind + every pre-008 social account to a provider-verified immutable subject. API + startup deliberately blocks while an enabled provider has unbound accounts. +- Keep liveness shallow. Readiness uses a bounded PostgreSQL check and rejects traffic when an embedded migration is missing or its recorded checksum has drifted. +- Restrict metrics, centralize structured redacted logs, and alert on auth failures, queue saturation, execution failures, and database pool exhaustion. +- Run the API as non-root with a read-only filesystem, dropped capabilities, resource limits, and graceful termination. +- Back up PostgreSQL and regularly test point-in-time restore. + +## Target execution plane + +```text +SPA -> API -> PostgreSQL + | + +-> durable queue -> isolated judge workers -> result store ``` -## Operational notes -- Development uses Docker Compose with Postgres and Redis -- Production should use managed Postgres and Redis with worker containers -- Backups should be taken regularly for PostgreSQL and important application secrets +The API should persist a job before enqueueing, workers should claim jobs with leases, and terminal result persistence should be idempotent. Redis, a managed queue, or a database-backed queue may be selected only when the worker implementation exists; none is claimed by the current repository. diff --git a/interviewhub-backend-go/go.mod b/interviewhub-backend-go/go.mod index 8250c894..13929850 100644 --- a/interviewhub-backend-go/go.mod +++ b/interviewhub-backend-go/go.mod @@ -3,15 +3,18 @@ module github.com/interviewhub/backend go 1.25.0 require ( - github.com/go-chi/chi/v5 v5.3.1 // indirect - github.com/go-chi/cors v1.2.2 // indirect - github.com/golang-jwt/jwt/v5 v5.3.1 // indirect - github.com/google/uuid v1.6.0 // indirect + github.com/go-chi/chi/v5 v5.3.1 + github.com/go-chi/cors v1.2.2 + github.com/golang-jwt/jwt/v5 v5.3.1 + github.com/google/uuid v1.6.0 + github.com/jackc/pgx/v5 v5.10.0 + golang.org/x/crypto v0.54.0 +) + +require ( github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect - github.com/jackc/pgx/v5 v5.10.0 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect - golang.org/x/crypto v0.54.0 // indirect golang.org/x/sync v0.22.0 // indirect golang.org/x/text v0.40.0 // indirect ) diff --git a/interviewhub-backend-go/go.sum b/interviewhub-backend-go/go.sum index fb263d72..e09d3241 100644 --- a/interviewhub-backend-go/go.sum +++ b/interviewhub-backend-go/go.sum @@ -1,4 +1,6 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8= github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE= @@ -15,10 +17,13 @@ github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0= github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= @@ -27,3 +32,5 @@ golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/interviewhub-backend-go/internal/app/application.go b/interviewhub-backend-go/internal/app/application.go new file mode 100644 index 00000000..7d0e021f --- /dev/null +++ b/interviewhub-backend-go/internal/app/application.go @@ -0,0 +1,97 @@ +// Package app owns the production dependency graph for the JudgeMyCode API. +// Keeping construction here prevents command entrypoints and tests from +// gradually assembling different routers, policies, or service lifecycles. +package app + +import ( + "context" + "errors" + "fmt" + "net/http" + "sync" + + "github.com/interviewhub/backend/internal/config" + "github.com/interviewhub/backend/internal/handlers" + "github.com/interviewhub/backend/internal/httpapi" + "github.com/interviewhub/backend/internal/services" + "github.com/interviewhub/backend/internal/store" +) + +// Application contains the HTTP surface and the resources whose lifecycles +// are owned by the API process. +type Application struct { + Handler http.Handler + + postgres *store.PostgresStore + executor *services.ExecutionService + close sync.Once + closeErr error +} + +// New connects infrastructure and builds the complete production dependency +// graph. Database migrations and demo seeds are intentionally not run here; +// deployments must execute cmd/seed as a separate, explicit step. +func New(ctx context.Context, cfg *config.Config) (*Application, error) { + postgres, err := store.NewPostgres(ctx, cfg) + if err != nil { + return nil, err + } + closeWithError := func(initial error) (*Application, error) { + if closeErr := postgres.Close(); closeErr != nil { + return nil, errors.Join(initial, closeErr) + } + return nil, initial + } + if err := postgres.ValidateMigrations(ctx); err != nil { + return closeWithError(fmt.Errorf("validate database migrations: %w", err)) + } + if providers := cfg.EnabledOAuthProviders(); len(providers) > 0 { + unbound, err := postgres.CountUnboundOAuthUsers(ctx, providers) + if err != nil { + return closeWithError(fmt.Errorf("validate OAuth identity rollout: %w", err)) + } + if unbound > 0 { + return closeWithError(fmt.Errorf("OAuth identity rollout blocked: %d legacy social account(s) need an explicit provider-subject binding; use cmd/oauth-bind before starting the API", unbound)) + } + } + + authService := services.NewAuthService(postgres, cfg) + oauthService := services.NewOAuthService(postgres, authService, cfg) + problemService := services.NewProblemService(postgres) + submissionService := services.NewSubmissionService(postgres) + executionService := services.NewExecutionService(postgres, cfg) + executionService.SetSubmissionRecorder(submissionService) + aiService := services.NewAIService(postgres, cfg) + userService := services.NewUserService(postgres) + workspaceService := services.NewWorkspaceService(postgres) + + apiHandlers := handlers.NewHandlers( + authService, + oauthService, + problemService, + executionService, + submissionService, + aiService, + userService, + workspaceService, + cfg, + postgres, + ) + authMiddleware := handlers.NewMiddleware(cfg, authService) + + return &Application{ + Handler: httpapi.NewRouter(cfg, apiHandlers, authMiddleware), + postgres: postgres, + executor: executionService, + }, nil +} + +// Close is safe to call more than once. Candidate workers are stopped before +// the database pool so an in-flight submit can finish its persistence path. +func (a *Application) Close() error { + a.close.Do(func() { + a.executor.Close() + a.closeErr = a.postgres.Close() + }) + return a.closeErr +} diff --git a/interviewhub-backend-go/internal/config/config.go b/interviewhub-backend-go/internal/config/config.go index 72dafe07..0710dd46 100644 --- a/interviewhub-backend-go/internal/config/config.go +++ b/interviewhub-backend-go/internal/config/config.go @@ -3,6 +3,7 @@ package config import ( "errors" "fmt" + "net" "os" "path/filepath" "strconv" @@ -11,29 +12,33 @@ import ( ) type Config struct { + Host string Port int JWTSecret string - DatabaseDir string + JWTSecretIsDefault bool SandboxTempDir string - SandboxTimeoutSec int AccessTokenTTL time.Duration RefreshTokenTTL time.Duration DatabaseURL string - RedisURL string AppEnv string AllowedOrigins []string + TrustedProxyCIDRs []string MaxRequestBytes int64 + ExecutionEnabled bool ExecutionRunner string - ExecutionQueueName string - ExecutionDockerEnabled bool + ExecutionQueueCapacity int + ExecutionWorkerCount int ExecutionTimeoutSec int ExecutionMaxFileCount int ExecutionMaxOutputBytes int64 - ExecutionMaxMemoryMB int - ExecutionMaxProcesses int - ExecutionCPUCores int FrontendURL string BackendURL string + AIAPIURL string + AIAPIKey string + AIModel string + AIRequestTimeout time.Duration + AIMaxOutputTokens int + AIExternalGuard bool GoogleClientID string GoogleClientSecret string GoogleRedirectURL string @@ -45,6 +50,8 @@ type Config struct { LinkedinRedirectURL string } +const developmentJWTSecret = "interviewhub-local-development-secret-change-before-deploying" + func LoadConfig() *Config { loadEnvFile(".env.local") loadEnvFile(".env") @@ -54,42 +61,46 @@ func LoadConfig() *Config { port = p } - jwtSecret := os.Getenv("JWT_SECRET") - if jwtSecret == "" { - jwtSecret = "interviewhub_super_secret_jwt_key_2026_lld_platform_go_engine" + jwtSecret := strings.TrimSpace(os.Getenv("JWT_SECRET")) + jwtSecretIsDefault := jwtSecret == "" + if jwtSecretIsDefault { + jwtSecret = developmentJWTSecret } cwd, _ := os.Getwd() - dataDir := filepath.Join(cwd, "data") tempDir := filepath.Join(cwd, "temp_sandboxes") frontendURL := stringFromEnv("FRONTEND_URL", "http://localhost:5173") backendURL := stringFromEnv("BACKEND_URL", fmt.Sprintf("http://localhost:%d", port)) return &Config{ + Host: stringFromEnv("HOST", "127.0.0.1"), Port: port, JWTSecret: jwtSecret, - DatabaseDir: dataDir, + JWTSecretIsDefault: jwtSecretIsDefault, SandboxTempDir: tempDir, - SandboxTimeoutSec: 6, AccessTokenTTL: durationFromEnv("ACCESS_TOKEN_TTL", 15*time.Minute), RefreshTokenTTL: durationFromEnv("REFRESH_TOKEN_TTL", 30*24*time.Hour), DatabaseURL: os.Getenv("DATABASE_URL"), - RedisURL: os.Getenv("REDIS_URL"), AppEnv: environment(), AllowedOrigins: csvEnv("ALLOWED_ORIGINS", "http://localhost:5173,http://localhost:3000,http://127.0.0.1:5173"), + TrustedProxyCIDRs: csvEnv("TRUSTED_PROXY_CIDRS", ""), MaxRequestBytes: int64FromEnv("MAX_REQUEST_BYTES", 2<<20), + ExecutionEnabled: boolFromEnv("EXECUTION_ENABLED", true), ExecutionRunner: strings.ToLower(strings.TrimSpace(os.Getenv("EXECUTION_RUNNER"))), - ExecutionQueueName: strings.TrimSpace(os.Getenv("EXECUTION_QUEUE_NAME")), - ExecutionDockerEnabled: boolFromEnv("EXECUTION_DOCKER_ENABLED", false), + ExecutionQueueCapacity: intFromEnv("EXECUTION_QUEUE_CAPACITY", 32), + ExecutionWorkerCount: intFromEnv("EXECUTION_WORKER_COUNT", 2), ExecutionTimeoutSec: intFromEnv("EXECUTION_TIMEOUT_SEC", 6), ExecutionMaxFileCount: intFromEnv("EXECUTION_MAX_FILE_COUNT", 50), ExecutionMaxOutputBytes: int64FromEnv("EXECUTION_MAX_OUTPUT_BYTES", 1<<20), - ExecutionMaxMemoryMB: intFromEnv("EXECUTION_MAX_MEMORY_MB", 256), - ExecutionMaxProcesses: intFromEnv("EXECUTION_MAX_PROCESSES", 64), - ExecutionCPUCores: intFromEnv("EXECUTION_CPU_CORES", 1), FrontendURL: frontendURL, BackendURL: backendURL, + AIAPIURL: strings.TrimSpace(os.Getenv("AI_API_URL")), + AIAPIKey: strings.TrimSpace(os.Getenv("AI_API_KEY")), + AIModel: strings.TrimSpace(os.Getenv("AI_MODEL")), + AIRequestTimeout: durationFromEnv("AI_REQUEST_TIMEOUT", 20*time.Second), + AIMaxOutputTokens: intFromEnv("AI_MAX_OUTPUT_TOKENS", 700), + AIExternalGuard: boolFromEnv("AI_EXTERNAL_GUARD", false), GoogleClientID: strings.TrimSpace(os.Getenv("GOOGLE_CLIENT_ID")), GoogleClientSecret: strings.TrimSpace(os.Getenv("GOOGLE_CLIENT_SECRET")), GoogleRedirectURL: stringFromEnv("GOOGLE_REDIRECT_URL", backendURL+"/api/auth/oauth/google/callback"), @@ -104,24 +115,84 @@ func LoadConfig() *Config { func (c *Config) IsProduction() bool { return c.AppEnv == "production" } +func (c *Config) EnabledOAuthProviders() []string { + providers := make([]string, 0, 3) + if c.GoogleClientID != "" && c.GoogleClientSecret != "" { + providers = append(providers, "google") + } + if c.GithubClientID != "" && c.GithubClientSecret != "" { + providers = append(providers, "github") + } + if c.LinkedinClientID != "" && c.LinkedinClientSecret != "" { + providers = append(providers, "linkedin") + } + return providers +} + func (c *Config) Validate() error { + switch c.AppEnv { + case "development", "test", "staging", "production": + default: + return fmt.Errorf("APP_ENV must be one of development, test, staging, or production; got %q", c.AppEnv) + } + if strings.TrimSpace(c.Host) == "" { + return errors.New("HOST is required") + } + for _, cidr := range c.TrustedProxyCIDRs { + if _, _, err := net.ParseCIDR(cidr); err != nil { + return fmt.Errorf("TRUSTED_PROXY_CIDRS contains invalid CIDR %q", cidr) + } + } if c.DatabaseURL == "" { return errors.New("DATABASE_URL is required; all application data is loaded from PostgreSQL (JSON mock store removed)") } - if c.IsProduction() && len(c.JWTSecret) < 32 { - return errors.New("JWT_SECRET must be set to at least 32 characters in production") + outsideLocalDevelopment := c.IsProduction() || c.AppEnv == "staging" + if outsideLocalDevelopment && (c.JWTSecretIsDefault || insecureJWTSecret(c.JWTSecret)) { + return errors.New("JWT_SECRET must be explicitly configured outside local development") + } + if outsideLocalDevelopment && len(c.JWTSecret) < 32 { + return errors.New("JWT_SECRET must be set to at least 32 characters outside local development") } if c.ExecutionRunner == "" { c.ExecutionRunner = "local" } - if c.ExecutionQueueName == "" { - c.ExecutionQueueName = "judge-jobs" + if c.ExecutionRunner != "local" && c.ExecutionRunner != "disabled" { + return fmt.Errorf("EXECUTION_RUNNER must be local or disabled; got %q", c.ExecutionRunner) + } + if (c.IsProduction() || c.AppEnv == "staging") && c.ExecutionEnabled && c.ExecutionRunner == "local" { + return errors.New("local host execution is unsafe outside development/test; set EXECUTION_ENABLED=false until an isolated judge runner is configured") } - if c.ExecutionCPUCores < 1 { - c.ExecutionCPUCores = 1 + aiConfigured := c.AIAPIURL != "" || c.AIAPIKey != "" || c.AIModel != "" + if aiConfigured && (c.AIAPIURL == "" || c.AIAPIKey == "" || c.AIModel == "") { + return errors.New("AI_API_URL, AI_API_KEY, and AI_MODEL must be configured together") } - if c.ExecutionMaxProcesses < 1 { - c.ExecutionMaxProcesses = 64 + if outsideLocalDevelopment && aiConfigured && !c.AIExternalGuard { + return errors.New("AI_EXTERNAL_GUARD=true is required outside local development to confirm a distributed quota and budget guard exists") + } + if c.AIMaxOutputTokens < 1 { + c.AIMaxOutputTokens = 700 + } + if c.AIMaxOutputTokens > 4096 { + return errors.New("AI_MAX_OUTPUT_TOKENS must not exceed 4096") + } + for _, provider := range []struct { + name string + id string + secret string + }{ + {name: "Google", id: c.GoogleClientID, secret: c.GoogleClientSecret}, + {name: "GitHub", id: c.GithubClientID, secret: c.GithubClientSecret}, + {name: "LinkedIn", id: c.LinkedinClientID, secret: c.LinkedinClientSecret}, + } { + if (provider.id == "") != (provider.secret == "") { + return fmt.Errorf("%s OAuth client ID and secret must be configured together", provider.name) + } + } + if c.ExecutionQueueCapacity < 1 { + c.ExecutionQueueCapacity = 32 + } + if c.ExecutionWorkerCount < 1 { + c.ExecutionWorkerCount = 2 } if c.ExecutionMaxFileCount < 1 { c.ExecutionMaxFileCount = 50 @@ -132,12 +203,22 @@ func (c *Config) Validate() error { if c.ExecutionTimeoutSec < 1 { c.ExecutionTimeoutSec = 6 } - if c.ExecutionMaxMemoryMB < 1 { - c.ExecutionMaxMemoryMB = 256 - } return nil } +func insecureJWTSecret(secret string) bool { + normalized := strings.ToLower(strings.TrimSpace(secret)) + if normalized == "" || normalized == developmentJWTSecret { + return true + } + for _, marker := range []string{"please_change", "please-change", "replace-with", "local-dev", "development-secret"} { + if strings.Contains(normalized, marker) { + return true + } + } + return false +} + func durationFromEnv(key string, fallback time.Duration) time.Duration { if value, err := time.ParseDuration(os.Getenv(key)); err == nil && value > 0 { return value @@ -228,5 +309,5 @@ func loadEnvFile(path string) { } func (c *Config) String() string { - return fmt.Sprintf("env=%s port=%d database=%t redis=%t executionRunner=%s", c.AppEnv, c.Port, c.DatabaseURL != "", c.RedisURL != "", c.ExecutionRunner) + return fmt.Sprintf("env=%s address=%s:%d database=%t executionEnabled=%t executionRunner=%s", c.AppEnv, c.Host, c.Port, c.DatabaseURL != "", c.ExecutionEnabled, c.ExecutionRunner) } diff --git a/interviewhub-backend-go/internal/config/config_test.go b/interviewhub-backend-go/internal/config/config_test.go new file mode 100644 index 00000000..44504bb5 --- /dev/null +++ b/interviewhub-backend-go/internal/config/config_test.go @@ -0,0 +1,122 @@ +package config + +import ( + "strings" + "testing" +) + +func validConfig() *Config { + return &Config{ + Host: "127.0.0.1", + DatabaseURL: "postgres://localhost/judgemycode", + JWTSecret: "test-secret-with-at-least-thirty-two-characters", + AppEnv: "test", + ExecutionEnabled: true, + ExecutionRunner: "local", + ExecutionQueueCapacity: 8, + ExecutionWorkerCount: 1, + } +} + +func TestValidateRejectsDefaultProductionSecret(t *testing.T) { + cfg := validConfig() + cfg.AppEnv = "production" + cfg.ExecutionEnabled = false + cfg.JWTSecret = developmentJWTSecret + cfg.JWTSecretIsDefault = true + + err := cfg.Validate() + if err == nil || !strings.Contains(err.Error(), "explicitly configured") { + t.Fatalf("expected explicit JWT secret error, got %v", err) + } +} + +func TestValidateRejectsLocalProductionExecution(t *testing.T) { + cfg := validConfig() + cfg.AppEnv = "production" + + err := cfg.Validate() + if err == nil || !strings.Contains(err.Error(), "unsafe outside") { + t.Fatalf("expected unsafe local execution error, got %v", err) + } +} + +func TestValidateRejectsLocalStagingExecution(t *testing.T) { + cfg := validConfig() + cfg.AppEnv = "staging" + + err := cfg.Validate() + if err == nil || !strings.Contains(err.Error(), "unsafe outside") { + t.Fatalf("expected unsafe local execution error, got %v", err) + } +} + +func TestValidateRejectsDefaultStagingSecret(t *testing.T) { + cfg := validConfig() + cfg.AppEnv = "staging" + cfg.ExecutionEnabled = false + cfg.JWTSecret = developmentJWTSecret + cfg.JWTSecretIsDefault = true + + err := cfg.Validate() + if err == nil || !strings.Contains(err.Error(), "explicitly configured") { + t.Fatalf("expected explicit staging JWT secret error, got %v", err) + } +} + +func TestValidateRejectsKnownPlaceholderStagingSecret(t *testing.T) { + cfg := validConfig() + cfg.AppEnv = "staging" + cfg.ExecutionEnabled = false + cfg.JWTSecret = "replace-with-a-long-random-secret-at-least-32-characters" + + err := cfg.Validate() + if err == nil || !strings.Contains(err.Error(), "explicitly configured") { + t.Fatalf("expected placeholder staging JWT secret error, got %v", err) + } +} + +func TestValidateRejectsKnownPlaceholderProductionSecret(t *testing.T) { + cfg := validConfig() + cfg.AppEnv = "production" + cfg.ExecutionEnabled = false + cfg.JWTSecret = "replace-with-a-long-random-secret-at-least-32-characters" + + err := cfg.Validate() + if err == nil || !strings.Contains(err.Error(), "explicitly configured") { + t.Fatalf("expected placeholder JWT secret error, got %v", err) + } +} + +func TestValidateAcceptsLocalTestConfiguration(t *testing.T) { + cfg := validConfig() + if err := cfg.Validate(); err != nil { + t.Fatalf("expected valid local test configuration, got %v", err) + } +} + +func TestValidateRejectsUnknownEnvironment(t *testing.T) { + cfg := validConfig() + cfg.AppEnv = "prod" + + err := cfg.Validate() + if err == nil || !strings.Contains(err.Error(), "APP_ENV") { + t.Fatalf("expected APP_ENV validation error, got %v", err) + } +} + +func TestValidateRejectsInvalidTrustedProxyCIDR(t *testing.T) { + cfg := validConfig() + cfg.TrustedProxyCIDRs = []string{"not-a-network"} + if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "TRUSTED_PROXY_CIDRS") { + t.Fatalf("expected trusted proxy validation error, got %v", err) + } +} + +func TestValidateRejectsPartialOAuthCredentials(t *testing.T) { + cfg := validConfig() + cfg.GoogleClientID = "client-only" + if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "Google OAuth") { + t.Fatalf("expected paired OAuth credential error, got %v", err) + } +} diff --git a/interviewhub-backend-go/internal/data/seeds.go b/interviewhub-backend-go/internal/data/seeds.go index 4e56d6e9..0e1d5e7d 100644 --- a/interviewhub-backend-go/internal/data/seeds.go +++ b/interviewhub-backend-go/internal/data/seeds.go @@ -26,7 +26,6 @@ func GetSeedProblems() []models.Problem { Language: models.LangJava, Content: `import java.util.*; import models.*; -import services.*; import controller.*; public class Main { @@ -589,10 +588,10 @@ public class LRUCache { ClassDiagramMermaid: "classDiagram\n class ParkingLot\n class ParkingSpot\n ParkingLot o-- ParkingSpot", StarterFiles: map[models.Language][]models.FileNode{ models.LangJava: parkingLotJavaFiles, - models.LangPython: parkingLotJavaFiles, - models.LangGo: parkingLotJavaFiles, - models.LangCpp: parkingLotJavaFiles, - models.LangTypeScript: parkingLotJavaFiles, + models.LangPython: parkingLotPythonFiles(), + models.LangGo: parkingLotGoFiles(), + models.LangCpp: parkingLotCppFiles(), + models.LangTypeScript: parkingLotTypeScriptFiles(), }, TestCases: []models.TestCase{ { @@ -675,10 +674,10 @@ public class LRUCache { ClassDiagramMermaid: "classDiagram\n class Show\n class Booking\n Show o-- Booking", StarterFiles: map[models.Language][]models.FileNode{ models.LangJava: bookMyShowJavaFiles, - models.LangPython: bookMyShowJavaFiles, - models.LangGo: bookMyShowJavaFiles, - models.LangCpp: bookMyShowJavaFiles, - models.LangTypeScript: bookMyShowJavaFiles, + models.LangPython: bookMyShowPythonFiles(), + models.LangGo: bookMyShowGoFiles(), + models.LangCpp: bookMyShowCppFiles(), + models.LangTypeScript: bookMyShowTypeScriptFiles(), }, TestCases: []models.TestCase{ { @@ -745,10 +744,10 @@ public class LRUCache { ClassDiagramMermaid: "classDiagram\n class RateLimiter", StarterFiles: map[models.Language][]models.FileNode{ models.LangJava: rateLimiterJavaFiles, - models.LangPython: rateLimiterJavaFiles, - models.LangGo: rateLimiterJavaFiles, - models.LangCpp: rateLimiterJavaFiles, - models.LangTypeScript: rateLimiterJavaFiles, + models.LangPython: rateLimiterPythonFiles(), + models.LangGo: rateLimiterGoFiles(), + models.LangCpp: rateLimiterCppFiles(), + models.LangTypeScript: rateLimiterTypeScriptFiles(), }, TestCases: []models.TestCase{ { @@ -816,10 +815,10 @@ public class LRUCache { ClassDiagramMermaid: "classDiagram\n class LRUCache", StarterFiles: map[models.Language][]models.FileNode{ models.LangJava: lruCacheJavaFiles, - models.LangPython: lruCacheJavaFiles, - models.LangGo: lruCacheJavaFiles, - models.LangCpp: lruCacheJavaFiles, - models.LangTypeScript: lruCacheJavaFiles, + models.LangPython: lruCachePythonFiles(), + models.LangGo: lruCacheGoFiles(), + models.LangCpp: lruCacheCppFiles(), + models.LangTypeScript: lruCacheTypeScriptFiles(), }, TestCases: []models.TestCase{ { diff --git a/interviewhub-backend-go/internal/data/seeds_test.go b/interviewhub-backend-go/internal/data/seeds_test.go new file mode 100644 index 00000000..407cdafb --- /dev/null +++ b/interviewhub-backend-go/internal/data/seeds_test.go @@ -0,0 +1,357 @@ +package data + +import ( + "context" + "fmt" + "go/ast" + "go/parser" + "go/token" + "os" + "os/exec" + "path" + "path/filepath" + "sort" + "strings" + "testing" + "time" + + "github.com/interviewhub/backend/internal/models" +) + +var starterExtensions = map[models.Language]map[string]bool{ + models.LangJava: {".java": true}, + models.LangPython: {".py": true}, + models.LangGo: {".go": true}, + models.LangCpp: {".cc": true, ".cpp": true, ".cxx": true, ".h": true, ".hpp": true}, + models.LangTypeScript: {".ts": true, ".tsx": true}, +} + +func TestSeedProblemsHaveValidLanguageStarterTrees(t *testing.T) { + problems := GetSeedProblems() + if len(problems) != 4 { + t.Fatalf("expected 4 seeded problems, got %d", len(problems)) + } + + for _, problem := range problems { + problem := problem + t.Run(problem.ID, func(t *testing.T) { + if len(problem.StarterFiles) != len(starterExtensions) { + t.Fatalf("advertises %d languages; want %d", len(problem.StarterFiles), len(starterExtensions)) + } + + for language, extensions := range starterExtensions { + language := language + extensions := extensions + t.Run(string(language), func(t *testing.T) { + nodes, ok := problem.StarterFiles[language] + if !ok { + t.Fatalf("missing %s starter tree", language) + } + if len(nodes) == 0 { + t.Fatalf("%s starter tree is empty", language) + } + + files := validateStarterNodes(t, language, extensions, nodes) + assertRunnableEntrypoint(t, language, files) + }) + } + }) + } +} + +func TestSeedStarterTreesAreFreshValues(t *testing.T) { + first := GetSeedProblems() + second := GetSeedProblems() + if len(first) == 0 || len(second) == 0 { + t.Fatal("expected seeded problems") + } + + firstMain := findFile(first[0].StarterFiles[models.LangPython], "src/main.py") + secondMain := findFile(second[0].StarterFiles[models.LangPython], "src/main.py") + if firstMain == nil || secondMain == nil { + t.Fatal("expected parking-lot Python entrypoints") + } + original := secondMain.Content + firstMain.Content = "candidate edit" + if secondMain.Content != original { + t.Fatal("starter trees share mutable candidate source state") + } +} + +// TestSeedStartersCompileAndRun is opt-in because CI hosts may intentionally +// omit one or more candidate runtimes. It gives maintainers a reproducible way +// to validate the complete seeded matrix before changing a template. +func TestSeedStartersCompileAndRun(t *testing.T) { + if os.Getenv("COMPILE_SEED_STARTERS") != "1" { + t.Skip("set COMPILE_SEED_STARTERS=1 to validate installed candidate runtimes") + } + + for _, problem := range GetSeedProblems() { + for language := range starterExtensions { + problem := problem + language := language + t.Run(problem.ID+"/"+string(language), func(t *testing.T) { + root := t.TempDir() + if err := stageStarterNodes(root, problem.StarterFiles[language]); err != nil { + t.Fatalf("stage starter tree: %v", err) + } + compileAndRunStarter(t, root, language) + }) + } + } +} + +func validateStarterNodes( + t *testing.T, + language models.Language, + extensions map[string]bool, + nodes []models.FileNode, +) map[string]string { + t.Helper() + paths := make(map[string]bool) + ids := make(map[string]bool) + files := make(map[string]string) + + var visit func([]models.FileNode) + visit = func(current []models.FileNode) { + for _, node := range current { + if node.ID == "" { + t.Error("starter node has an empty ID") + } else if ids[node.ID] { + t.Errorf("duplicate starter node ID %q", node.ID) + } + ids[node.ID] = true + + if node.Path == "" || path.IsAbs(node.Path) || path.Clean(node.Path) != node.Path || + node.Path == "." || node.Path == ".." || strings.HasPrefix(node.Path, "../") || + strings.Contains(node.Path, "\\") { + t.Errorf("unsafe or non-canonical starter path %q", node.Path) + } + if paths[node.Path] { + t.Errorf("duplicate starter path %q", node.Path) + } + paths[node.Path] = true + if node.Name != path.Base(node.Path) { + t.Errorf("node name %q does not match path %q", node.Name, node.Path) + } + + if node.IsDirectory { + if node.Content != "" { + t.Errorf("directory %q contains source text", node.Path) + } + if node.Language != "" && node.Language != language { + t.Errorf("directory %q is tagged %q in %q tree", node.Path, node.Language, language) + } + visit(node.Children) + continue + } + + if len(node.Children) != 0 { + t.Errorf("source file %q contains child nodes", node.Path) + } + if node.Language != language { + t.Errorf("source file %q is tagged %q in %q tree", node.Path, node.Language, language) + } + extension := strings.ToLower(path.Ext(node.Path)) + if !extensions[extension] { + t.Errorf("source file %q has incompatible extension for %s", node.Path, language) + } + if strings.TrimSpace(node.Content) == "" { + t.Errorf("source file %q is empty", node.Path) + } + files[node.Path] = node.Content + } + } + + visit(nodes) + if len(files) == 0 { + t.Fatalf("%s starter tree contains no source files", language) + } + return files +} + +func assertRunnableEntrypoint(t *testing.T, language models.Language, files map[string]string) { + t.Helper() + var entrypoint string + var marker string + switch language { + case models.LangJava: + entrypoint, marker = "src/Main.java", "public static void main(String[] args)" + case models.LangPython: + entrypoint, marker = "src/main.py", `if __name__ == "__main__":` + case models.LangCpp: + entrypoint, marker = "src/main.cpp", "int main()" + case models.LangTypeScript: + entrypoint, marker = "src/main.ts", "console.log(" + case models.LangGo: + entrypoint = "src/main.go" + default: + t.Fatalf("unsupported starter language %q", language) + } + + content, ok := files[entrypoint] + if !ok { + t.Fatalf("missing runner-compatible entrypoint %q", entrypoint) + } + if language == models.LangGo { + parsed, err := parser.ParseFile(token.NewFileSet(), entrypoint, content, parser.AllErrors) + if err != nil { + t.Fatalf("Go entrypoint is not valid syntax: %v", err) + } + for _, declaration := range parsed.Decls { + function, ok := declaration.(*ast.FuncDecl) + if ok && function.Recv == nil && function.Name.Name == "main" { + return + } + } + t.Fatal("Go entrypoint does not declare func main") + } + if !strings.Contains(content, marker) { + t.Fatalf("entrypoint %q does not contain runnable marker %q", entrypoint, marker) + } +} + +func findFile(nodes []models.FileNode, wantedPath string) *models.FileNode { + for index := range nodes { + if nodes[index].Path == wantedPath && !nodes[index].IsDirectory { + return &nodes[index] + } + if found := findFile(nodes[index].Children, wantedPath); found != nil { + return found + } + } + return nil +} + +func stageStarterNodes(root string, nodes []models.FileNode) error { + for _, node := range nodes { + target := filepath.Join(root, filepath.FromSlash(node.Path)) + if node.IsDirectory { + if err := os.MkdirAll(target, 0o700); err != nil { + return err + } + } else { + if err := os.MkdirAll(filepath.Dir(target), 0o700); err != nil { + return err + } + if err := os.WriteFile(target, []byte(node.Content), 0o600); err != nil { + return err + } + } + if err := stageStarterNodes(root, node.Children); err != nil { + return err + } + } + return nil +} + +func compileAndRunStarter(t *testing.T, root string, language models.Language) { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + run := func(binary string, args ...string) { + t.Helper() + command := exec.CommandContext(ctx, binary, args...) + command.Dir = root + if output, err := command.CombinedOutput(); err != nil { + t.Fatalf("%s %s: %v\n%s", binary, strings.Join(args, " "), err, output) + } + } + + switch language { + case models.LangJava: + javac := requireStarterTool(t, "javac") + java := requireStarterTool(t, "java") + files := starterSourcePaths(t, root, ".java") + if err := os.MkdirAll(filepath.Join(root, "out"), 0o700); err != nil { + t.Fatal(err) + } + run(javac, append([]string{"-d", "out"}, files...)...) + run(java, "-cp", "out", "Main") + case models.LangPython: + run(requireStarterTool(t, "python3"), "src/main.py") + case models.LangGo: + files := starterSourcePaths(t, root, ".go") + run(requireStarterTool(t, "go"), append([]string{"run"}, files...)...) + case models.LangCpp: + compiler := requireStarterTool(t, "g++") + files := starterSourcePaths(t, root, ".cc", ".cpp", ".cxx") + if err := os.MkdirAll(filepath.Join(root, "out"), 0o700); err != nil { + t.Fatal(err) + } + run(compiler, append([]string{"-std=c++17", "-Wall", "-Wextra", "-pedantic", "-o", "out/app"}, files...)...) + run(filepath.Join(root, "out", "app")) + case models.LangTypeScript: + tsc := findTypeScriptCompiler(t) + files := starterSourcePaths(t, root, ".ts", ".tsx") + args := []string{ + "--strict", "--noEmit", "--target", "ES2022", "--module", "nodenext", + "--moduleResolution", "nodenext", "--allowImportingTsExtensions", + } + run(tsc, append(args, files...)...) + run(requireStarterTool(t, "node"), "--experimental-transform-types", "src/main.ts") + default: + t.Fatalf("unsupported starter language %q", language) + } + if ctx.Err() != nil { + t.Fatalf("starter verification timed out: %v", ctx.Err()) + } +} + +func requireStarterTool(t *testing.T, name string) string { + t.Helper() + binary, err := exec.LookPath(name) + if err != nil { + t.Skipf("candidate runtime %q is not installed", name) + } + return binary +} + +func findTypeScriptCompiler(t *testing.T) string { + t.Helper() + if binary, err := exec.LookPath("tsc"); err == nil { + return binary + } + workingDirectory, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + candidate := filepath.Clean(filepath.Join(workingDirectory, "..", "..", "..", "interviewhub-lld", "node_modules", ".bin", "tsc")) + if info, err := os.Stat(candidate); err == nil && !info.IsDir() { + return candidate + } + t.Skip("TypeScript compiler is not installed") + return "" +} + +func starterSourcePaths(t *testing.T, root string, extensions ...string) []string { + t.Helper() + allowed := make(map[string]bool, len(extensions)) + for _, extension := range extensions { + allowed[extension] = true + } + var files []string + err := filepath.WalkDir(root, func(current string, entry os.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if entry.IsDir() || !allowed[strings.ToLower(filepath.Ext(current))] { + return nil + } + relative, err := filepath.Rel(root, current) + if err != nil { + return err + } + files = append(files, relative) + return nil + }) + if err != nil { + t.Fatal(err) + } + if len(files) == 0 { + t.Fatal(fmt.Errorf("no starter sources found for extensions %v", extensions)) + } + sort.Strings(files) + return files +} diff --git a/interviewhub-backend-go/internal/data/starter_files.go b/interviewhub-backend-go/internal/data/starter_files.go new file mode 100644 index 00000000..eaeecacf --- /dev/null +++ b/interviewhub-backend-go/internal/data/starter_files.go @@ -0,0 +1,905 @@ +package data + +import ( + "fmt" + "strings" + + "github.com/interviewhub/backend/internal/models" +) + +type starterSource struct { + name string + content string +} + +// starterFileTree builds the same root-relative tree shape consumed by both the +// workspace editor and the local execution runner. Every invocation returns a +// fresh tree so one candidate's edits cannot mutate another language template. +func starterFileTree(problemID string, language models.Language, sources ...starterSource) []models.FileNode { + children := make([]models.FileNode, 0, len(sources)) + for _, source := range sources { + fileID := strings.NewReplacer(".", "-", "_", "-").Replace(source.name) + children = append(children, models.FileNode{ + ID: fmt.Sprintf("%s-%s-%s", problemID, language, fileID), + Name: source.name, + Path: "src/" + source.name, + Content: source.content, + IsDirectory: false, + Language: language, + }) + } + + return []models.FileNode{{ + ID: fmt.Sprintf("%s-%s-src", problemID, language), + Name: "src", + Path: "src", + IsDirectory: true, + Children: children, + }} +} + +func parkingLotPythonFiles() []models.FileNode { + return starterFileTree("parking-lot", models.LangPython, + starterSource{name: "design.py", content: `from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum +from typing import Protocol + + +class SpotType(Enum): + MOTORCYCLE = "motorcycle" + COMPACT = "compact" + LARGE = "large" + EV = "ev" + + +@dataclass(frozen=True) +class Vehicle: + license_plate: str + required_spot_type: SpotType + + +@dataclass(frozen=True) +class Ticket: + ticket_id: str + spot_id: str + + +class PricingStrategy(Protocol): + def fee_in_cents(self, parked_minutes: int) -> int: ... + + +class ParkingLot: + def __init__(self, capacity_by_type: dict[SpotType, int]) -> None: + self._capacity_by_type = dict(capacity_by_type) + + @property + def total_capacity(self) -> int: + return sum(self._capacity_by_type.values()) + + def park(self, vehicle: Vehicle) -> Ticket: + # TODO: allocate a compatible free spot atomically and issue a ticket. + raise NotImplementedError("implement ParkingLot.park") + + def unpark(self, ticket: Ticket, pricing: PricingStrategy) -> int: + # TODO: release the assigned spot and calculate the payment. + raise NotImplementedError("implement ParkingLot.unpark") +`}, + starterSource{name: "main.py", content: `from design import ParkingLot, SpotType + + +def main() -> None: + parking_lot = ParkingLot({ + SpotType.MOTORCYCLE: 10, + SpotType.COMPACT: 20, + SpotType.LARGE: 5, + }) + print(f"Parking lot starter ready: {parking_lot.total_capacity} spots") + + +if __name__ == "__main__": + main() +`}, + ) +} + +func parkingLotGoFiles() []models.FileNode { + return starterFileTree("parking-lot", models.LangGo, + starterSource{name: "design.go", content: `package main + +import ( + "errors" + "sync" +) + +type SpotType string + +const ( + SpotMotorcycle SpotType = "motorcycle" + SpotCompact SpotType = "compact" + SpotLarge SpotType = "large" + SpotEV SpotType = "ev" +) + +type Vehicle struct { + LicensePlate string + RequiredSpotType SpotType +} + +type Ticket struct { + ID string + SpotID string +} + +type PricingStrategy interface { + FeeInCents(parkedMinutes int) int +} + +type ParkingLot struct { + mu sync.Mutex + capacity map[SpotType]int +} + +func NewParkingLot(capacity map[SpotType]int) *ParkingLot { + return &ParkingLot{capacity: capacity} +} + +func (p *ParkingLot) TotalCapacity() int { + total := 0 + for _, count := range p.capacity { + total += count + } + return total +} + +func (p *ParkingLot) Park(vehicle Vehicle) (Ticket, error) { + // TODO: allocate a compatible free spot atomically and issue a ticket. + return Ticket{}, errors.New("implement ParkingLot.Park") +} + +func (p *ParkingLot) Unpark(ticket Ticket, pricing PricingStrategy) (int, error) { + // TODO: release the assigned spot and calculate the payment. + return 0, errors.New("implement ParkingLot.Unpark") +} +`}, + starterSource{name: "main.go", content: `package main + +import "fmt" + +func main() { + parkingLot := NewParkingLot(map[SpotType]int{ + SpotMotorcycle: 10, + SpotCompact: 20, + SpotLarge: 5, + }) + fmt.Printf("Parking lot starter ready: %d spots\n", parkingLot.TotalCapacity()) +} +`}, + ) +} + +func parkingLotCppFiles() []models.FileNode { + return starterFileTree("parking-lot", models.LangCpp, + starterSource{name: "design.hpp", content: `#pragma once + +#include +#include +#include +#include + +enum class SpotType { Motorcycle, Compact, Large, EV }; + +struct Vehicle { + std::string licensePlate; + SpotType requiredSpotType; +}; + +struct Ticket { + std::string id; + std::string spotId; +}; + +class PricingStrategy { +public: + virtual ~PricingStrategy() = default; + virtual int feeInCents(int parkedMinutes) const = 0; +}; + +class ParkingLot { +public: + explicit ParkingLot(std::unordered_map capacity) + : capacity_(std::move(capacity)) {} + + int totalCapacity() const { + int total = 0; + for (const auto& entry : capacity_) total += entry.second; + return total; + } + + Ticket park(const Vehicle&) { + // TODO: allocate a compatible free spot atomically and issue a ticket. + throw std::logic_error("implement ParkingLot::park"); + } + + int unpark(const Ticket&, const PricingStrategy&) { + // TODO: release the assigned spot and calculate the payment. + throw std::logic_error("implement ParkingLot::unpark"); + } + +private: + std::unordered_map capacity_; +}; +`}, + starterSource{name: "main.cpp", content: `#include "design.hpp" + +#include + +int main() { + ParkingLot parkingLot({ + {SpotType::Motorcycle, 10}, + {SpotType::Compact, 20}, + {SpotType::Large, 5}, + }); + std::cout << "Parking lot starter ready: " << parkingLot.totalCapacity() + << " spots\n"; + return 0; +} +`}, + ) +} + +func parkingLotTypeScriptFiles() []models.FileNode { + return starterFileTree("parking-lot", models.LangTypeScript, + starterSource{name: "design.ts", content: `export enum SpotType { + Motorcycle = "motorcycle", + Compact = "compact", + Large = "large", + EV = "ev", +} + +export interface Vehicle { + readonly licensePlate: string; + readonly requiredSpotType: SpotType; +} + +export interface Ticket { + readonly id: string; + readonly spotId: string; +} + +export interface PricingStrategy { + feeInCents(parkedMinutes: number): number; +} + +export class ParkingLot { + constructor(private readonly capacityByType: ReadonlyMap) {} + + totalCapacity(): number { + return [...this.capacityByType.values()].reduce((total, count) => total + count, 0); + } + + park(_vehicle: Vehicle): Ticket { + // TODO: allocate a compatible free spot atomically and issue a ticket. + throw new Error("implement ParkingLot.park"); + } + + unpark(_ticket: Ticket, _pricing: PricingStrategy): number { + // TODO: release the assigned spot and calculate the payment. + throw new Error("implement ParkingLot.unpark"); + } +} +`}, + starterSource{name: "main.ts", content: `import { ParkingLot, SpotType } from "./design.ts"; + +const parkingLot = new ParkingLot(new Map([ + [SpotType.Motorcycle, 10], + [SpotType.Compact, 20], + [SpotType.Large, 5], +])); + +console.log(` + "`" + `Parking lot starter ready: ${parkingLot.totalCapacity()} spots` + "`" + `); +`}, + ) +} + +func bookMyShowPythonFiles() []models.FileNode { + return starterFileTree("bookmyshow", models.LangPython, + starterSource{name: "booking.py", content: `from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +from enum import Enum + + +class SeatStatus(Enum): + AVAILABLE = "available" + LOCKED = "locked" + BOOKED = "booked" + + +@dataclass(frozen=True) +class SeatLock: + token: str + show_id: str + seat_ids: tuple[str, ...] + user_id: str + expires_at: datetime + + +class BookingService: + def search_shows(self, movie: str, city: str) -> list[str]: + # TODO: query a show repository using normalized search criteria. + return [] + + def lock_seats( + self, show_id: str, seat_ids: list[str], user_id: str + ) -> SeatLock: + # TODO: atomically lock available seats and assign an expiration time. + raise NotImplementedError("implement BookingService.lock_seats") + + def confirm_booking(self, lock_token: str) -> str: + # TODO: validate the lock, capture payment, and transition seat state. + raise NotImplementedError("implement BookingService.confirm_booking") +`}, + starterSource{name: "main.py", content: `from booking import BookingService + + +def main() -> None: + service = BookingService() + print(f"Booking starter ready: {len(service.search_shows('Interstellar', 'New York'))} shows loaded") + + +if __name__ == "__main__": + main() +`}, + ) +} + +func bookMyShowGoFiles() []models.FileNode { + return starterFileTree("bookmyshow", models.LangGo, + starterSource{name: "booking.go", content: `package main + +import ( + "errors" + "sync" + "time" +) + +type SeatStatus string + +const ( + SeatAvailable SeatStatus = "available" + SeatLocked SeatStatus = "locked" + SeatBooked SeatStatus = "booked" +) + +type SeatLock struct { + Token string + ShowID string + SeatIDs []string + UserID string + ExpiresAt time.Time +} + +type BookingService struct { + mu sync.Mutex + locks map[string]SeatLock +} + +func NewBookingService() *BookingService { + return &BookingService{locks: make(map[string]SeatLock)} +} + +func (s *BookingService) SearchShows(movie, city string) []string { + // TODO: query a show repository using normalized search criteria. + return nil +} + +func (s *BookingService) LockSeats(showID string, seatIDs []string, userID string) (SeatLock, error) { + // TODO: atomically lock available seats and assign an expiration time. + return SeatLock{}, errors.New("implement BookingService.LockSeats") +} + +func (s *BookingService) ConfirmBooking(lockToken string) (string, error) { + // TODO: validate the lock, capture payment, and transition seat state. + return "", errors.New("implement BookingService.ConfirmBooking") +} +`}, + starterSource{name: "main.go", content: `package main + +import "fmt" + +func main() { + service := NewBookingService() + shows := service.SearchShows("Interstellar", "New York") + fmt.Printf("Booking starter ready: %d shows loaded\n", len(shows)) +} +`}, + ) +} + +func bookMyShowCppFiles() []models.FileNode { + return starterFileTree("bookmyshow", models.LangCpp, + starterSource{name: "booking.hpp", content: `#pragma once + +#include +#include +#include +#include + +enum class SeatStatus { Available, Locked, Booked }; + +struct SeatLock { + std::string token; + std::string showId; + std::vector seatIds; + std::string userId; + std::chrono::system_clock::time_point expiresAt; +}; + +class BookingService { +public: + std::vector searchShows( + const std::string& movie, + const std::string& city + ) const { + // TODO: query a show repository using normalized search criteria. + return {}; + } + + SeatLock lockSeats( + const std::string&, + const std::vector&, + const std::string& + ) { + // TODO: atomically lock available seats and assign an expiration time. + throw std::logic_error("implement BookingService::lockSeats"); + } + + std::string confirmBooking(const std::string&) { + // TODO: validate the lock, capture payment, and transition seat state. + throw std::logic_error("implement BookingService::confirmBooking"); + } +}; +`}, + starterSource{name: "main.cpp", content: `#include "booking.hpp" + +#include + +int main() { + BookingService service; + const auto shows = service.searchShows("Interstellar", "New York"); + std::cout << "Booking starter ready: " << shows.size() << " shows loaded\n"; + return 0; +} +`}, + ) +} + +func bookMyShowTypeScriptFiles() []models.FileNode { + return starterFileTree("bookmyshow", models.LangTypeScript, + starterSource{name: "booking.ts", content: `export enum SeatStatus { + Available = "available", + Locked = "locked", + Booked = "booked", +} + +export interface SeatLock { + readonly token: string; + readonly showId: string; + readonly seatIds: readonly string[]; + readonly userId: string; + readonly expiresAt: Date; +} + +export class BookingService { + searchShows(_movie: string, _city: string): readonly string[] { + // TODO: query a show repository using normalized search criteria. + return []; + } + + lockSeats(_showId: string, _seatIds: readonly string[], _userId: string): SeatLock { + // TODO: atomically lock available seats and assign an expiration time. + throw new Error("implement BookingService.lockSeats"); + } + + confirmBooking(_lockToken: string): string { + // TODO: validate the lock, capture payment, and transition seat state. + throw new Error("implement BookingService.confirmBooking"); + } +} +`}, + starterSource{name: "main.ts", content: `import { BookingService } from "./booking.ts"; + +const service = new BookingService(); +const shows = service.searchShows("Interstellar", "New York"); +console.log(` + "`" + `Booking starter ready: ${shows.length} shows loaded` + "`" + `); +`}, + ) +} + +func rateLimiterPythonFiles() []models.FileNode { + return starterFileTree("rate-limiter", models.LangPython, + starterSource{name: "rate_limiter.py", content: `from __future__ import annotations + +from dataclasses import dataclass +from threading import Lock + + +@dataclass(frozen=True) +class RateLimitPolicy: + capacity: int + refill_tokens_per_second: float + + +class RateLimiter: + def __init__(self) -> None: + self._policies: dict[str, RateLimitPolicy] = {} + self._lock = Lock() + + def configure_tier(self, tier: str, policy: RateLimitPolicy) -> None: + if policy.capacity <= 0 or policy.refill_tokens_per_second <= 0: + raise ValueError("rate limit values must be positive") + with self._lock: + self._policies[tier] = policy + + def is_allowed(self, client_id: str, tier: str) -> bool: + # TODO: obtain the client's bucket and consume one token atomically. + raise NotImplementedError("implement RateLimiter.is_allowed") + + @property + def configured_tier_count(self) -> int: + with self._lock: + return len(self._policies) +`}, + starterSource{name: "main.py", content: `from rate_limiter import RateLimiter, RateLimitPolicy + + +def main() -> None: + limiter = RateLimiter() + limiter.configure_tier("FREE", RateLimitPolicy(5, 5 / 60)) + print(f"Rate limiter starter ready: {limiter.configured_tier_count} tier configured") + + +if __name__ == "__main__": + main() +`}, + ) +} + +func rateLimiterGoFiles() []models.FileNode { + return starterFileTree("rate-limiter", models.LangGo, + starterSource{name: "rate_limiter.go", content: `package main + +import ( + "errors" + "sync" +) + +type RateLimitPolicy struct { + Capacity int + RefillTokensPerSecond float64 +} + +type RateLimiter struct { + mu sync.RWMutex + policies map[string]RateLimitPolicy +} + +func NewRateLimiter() *RateLimiter { + return &RateLimiter{policies: make(map[string]RateLimitPolicy)} +} + +func (r *RateLimiter) ConfigureTier(tier string, policy RateLimitPolicy) error { + if policy.Capacity <= 0 || policy.RefillTokensPerSecond <= 0 { + return errors.New("rate limit values must be positive") + } + r.mu.Lock() + defer r.mu.Unlock() + r.policies[tier] = policy + return nil +} + +func (r *RateLimiter) IsAllowed(clientID, tier string) bool { + // TODO: obtain the client's bucket and consume one token atomically. + return false +} + +func (r *RateLimiter) ConfiguredTierCount() int { + r.mu.RLock() + defer r.mu.RUnlock() + return len(r.policies) +} +`}, + starterSource{name: "main.go", content: `package main + +import "fmt" + +func main() { + limiter := NewRateLimiter() + if err := limiter.ConfigureTier("FREE", RateLimitPolicy{Capacity: 5, RefillTokensPerSecond: 5.0 / 60.0}); err != nil { + panic(err) + } + fmt.Printf("Rate limiter starter ready: %d tier configured\n", limiter.ConfiguredTierCount()) +} +`}, + ) +} + +func rateLimiterCppFiles() []models.FileNode { + return starterFileTree("rate-limiter", models.LangCpp, + starterSource{name: "rate_limiter.hpp", content: `#pragma once + +#include +#include +#include +#include + +struct RateLimitPolicy { + int capacity; + double refillTokensPerSecond; +}; + +class RateLimiter { +public: + void configureTier(const std::string& tier, RateLimitPolicy policy) { + if (policy.capacity <= 0 || policy.refillTokensPerSecond <= 0) { + throw std::invalid_argument("rate limit values must be positive"); + } + std::lock_guard guard(mutex_); + policies_[tier] = policy; + } + + bool isAllowed(const std::string& clientId, const std::string& tier) { + // TODO: obtain the client's bucket and consume one token atomically. + return false; + } + + std::size_t configuredTierCount() const { + std::lock_guard guard(mutex_); + return policies_.size(); + } + +private: + mutable std::mutex mutex_; + std::unordered_map policies_; +}; +`}, + starterSource{name: "main.cpp", content: `#include "rate_limiter.hpp" + +#include + +int main() { + RateLimiter limiter; + limiter.configureTier("FREE", RateLimitPolicy{5, 5.0 / 60.0}); + std::cout << "Rate limiter starter ready: " << limiter.configuredTierCount() + << " tier configured\n"; + return 0; +} +`}, + ) +} + +func rateLimiterTypeScriptFiles() []models.FileNode { + return starterFileTree("rate-limiter", models.LangTypeScript, + starterSource{name: "rate_limiter.ts", content: `export interface RateLimitPolicy { + readonly capacity: number; + readonly refillTokensPerSecond: number; +} + +export class RateLimiter { + private readonly policies = new Map(); + + configureTier(tier: string, policy: RateLimitPolicy): void { + if (policy.capacity <= 0 || policy.refillTokensPerSecond <= 0) { + throw new Error("rate limit values must be positive"); + } + this.policies.set(tier, policy); + } + + isAllowed(_clientId: string, _tier: string): boolean { + // TODO: obtain the client's bucket and consume one token atomically. + return false; + } + + configuredTierCount(): number { + return this.policies.size; + } +} +`}, + starterSource{name: "main.ts", content: `import { RateLimiter } from "./rate_limiter.ts"; + +const limiter = new RateLimiter(); +limiter.configureTier("FREE", { capacity: 5, refillTokensPerSecond: 5 / 60 }); +console.log(` + "`" + `Rate limiter starter ready: ${limiter.configuredTierCount()} tier configured` + "`" + `); +`}, + ) +} + +func lruCachePythonFiles() []models.FileNode { + return starterFileTree("lru-cache", models.LangPython, + starterSource{name: "cache.py", content: `from __future__ import annotations + +from threading import RLock +from typing import Generic, Optional, Protocol, TypeVar + +K = TypeVar("K") +V = TypeVar("V") + + +class Cache(Protocol[K, V]): + def get(self, key: K) -> Optional[V]: ... + def put(self, key: K, value: V) -> None: ... + + +class LRUCache(Generic[K, V]): + def __init__(self, capacity: int) -> None: + if capacity <= 0: + raise ValueError("capacity must be positive") + self._capacity = capacity + self._lock = RLock() + + @property + def capacity(self) -> int: + return self._capacity + + def get(self, key: K) -> Optional[V]: + # TODO: return the value and promote its node to most recently used. + raise NotImplementedError("implement LRUCache.get") + + def put(self, key: K, value: V) -> None: + # TODO: insert/update in O(1) and evict the least recently used node. + raise NotImplementedError("implement LRUCache.put") +`}, + starterSource{name: "main.py", content: `from cache import LRUCache + + +def main() -> None: + cache: LRUCache[str, int] = LRUCache(3) + print(f"LRU cache starter ready: capacity {cache.capacity}") + + +if __name__ == "__main__": + main() +`}, + ) +} + +func lruCacheGoFiles() []models.FileNode { + return starterFileTree("lru-cache", models.LangGo, + starterSource{name: "cache.go", content: `package main + +import ( + "errors" + "sync" +) + +type Cache[K comparable, V any] interface { + Get(key K) (V, bool) + Put(key K, value V) error +} + +type LRUCache[K comparable, V any] struct { + mu sync.Mutex + capacity int +} + +func NewLRUCache[K comparable, V any](capacity int) (*LRUCache[K, V], error) { + if capacity <= 0 { + return nil, errors.New("capacity must be positive") + } + return &LRUCache[K, V]{capacity: capacity}, nil +} + +func (c *LRUCache[K, V]) Capacity() int { + return c.capacity +} + +func (c *LRUCache[K, V]) Get(key K) (V, bool) { + // TODO: return the value and promote its node to most recently used. + var zero V + return zero, false +} + +func (c *LRUCache[K, V]) Put(key K, value V) error { + // TODO: insert/update in O(1) and evict the least recently used node. + return errors.New("implement LRUCache.Put") +} +`}, + starterSource{name: "main.go", content: `package main + +import "fmt" + +func main() { + cache, err := NewLRUCache[string, int](3) + if err != nil { + panic(err) + } + fmt.Printf("LRU cache starter ready: capacity %d\n", cache.Capacity()) +} +`}, + ) +} + +func lruCacheCppFiles() []models.FileNode { + return starterFileTree("lru-cache", models.LangCpp, + starterSource{name: "cache.hpp", content: `#pragma once + +#include +#include +#include + +template +class Cache { +public: + virtual ~Cache() = default; + virtual std::optional get(const Key& key) = 0; + virtual void put(Key key, Value value) = 0; +}; + +template +class LRUCache final : public Cache { +public: + explicit LRUCache(std::size_t capacity) : capacity_(capacity) { + if (capacity == 0) throw std::invalid_argument("capacity must be positive"); + } + + std::size_t capacity() const { return capacity_; } + + std::optional get(const Key&) override { + // TODO: return the value and promote its node to most recently used. + return std::nullopt; + } + + void put(Key, Value) override { + // TODO: insert/update in O(1) and evict the least recently used node. + } + +private: + std::size_t capacity_; +}; +`}, + starterSource{name: "main.cpp", content: `#include "cache.hpp" + +#include +#include + +int main() { + LRUCache cache(3); + std::cout << "LRU cache starter ready: capacity " << cache.capacity() << "\n"; + return 0; +} +`}, + ) +} + +func lruCacheTypeScriptFiles() []models.FileNode { + return starterFileTree("lru-cache", models.LangTypeScript, + starterSource{name: "cache.ts", content: `export interface Cache { + get(key: K): V | undefined; + put(key: K, value: V): void; +} + +export class LRUCache implements Cache { + constructor(readonly capacity: number) { + if (capacity <= 0) throw new Error("capacity must be positive"); + } + + get(_key: K): V | undefined { + // TODO: return the value and promote its node to most recently used. + return undefined; + } + + put(_key: K, _value: V): void { + // TODO: insert/update in O(1) and evict the least recently used node. + } +} +`}, + starterSource{name: "main.ts", content: `import { LRUCache } from "./cache.ts"; + +const cache = new LRUCache(3); +console.log(` + "`" + `LRU cache starter ready: capacity ${cache.capacity}` + "`" + `); +`}, + ) +} diff --git a/interviewhub-backend-go/internal/handlers/ai_handlers.go b/interviewhub-backend-go/internal/handlers/ai_handlers.go new file mode 100644 index 00000000..f9c7c8be --- /dev/null +++ b/interviewhub-backend-go/internal/handlers/ai_handlers.go @@ -0,0 +1,44 @@ +package handlers + +import ( + "errors" + "net/http" + + "github.com/interviewhub/backend/internal/services" +) + +type AIChatRequest struct { + ProblemID string `json:"problemId"` + Prompt string `json:"prompt"` + CodeSnippet string `json:"codeSnippet"` +} + +func (h *Handlers) AIChat(w http.ResponseWriter, r *http.Request) { + claims, ok := claimsFromRequest(r) + if !ok { + SendError(w, http.StatusUnauthorized, "authentication required") + return + } + var request AIChatRequest + if err := decodeJSON(r, &request); err != nil { + SendError(w, http.StatusBadRequest, "invalid AI request payload") + return + } + message, err := h.aiService.GenerateInterviewFeedback(r.Context(), claims.ID, clientAddress(r, h.cfg), request.ProblemID, request.Prompt, request.CodeSnippet) + if err != nil { + switch { + case errors.Is(err, services.ErrAIProblemNotFound): + SendError(w, http.StatusNotFound, err.Error()) + case errors.Is(err, services.ErrAINotConfigured), errors.Is(err, services.ErrAIProviderFailure): + SendError(w, http.StatusServiceUnavailable, err.Error()) + case errors.Is(err, services.ErrDependencyUnavailable): + SendError(w, http.StatusServiceUnavailable, "AI problem context is temporarily unavailable") + case errors.Is(err, services.ErrAIRateLimited), errors.Is(err, services.ErrAIBusy): + SendError(w, http.StatusTooManyRequests, err.Error()) + default: + SendError(w, http.StatusBadRequest, err.Error()) + } + return + } + SendSuccess(w, http.StatusOK, message) +} diff --git a/interviewhub-backend-go/internal/handlers/auth_handlers.go b/interviewhub-backend-go/internal/handlers/auth_handlers.go new file mode 100644 index 00000000..1867a75f --- /dev/null +++ b/interviewhub-backend-go/internal/handlers/auth_handlers.go @@ -0,0 +1,266 @@ +package handlers + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "net/http" + "strings" + + "github.com/interviewhub/backend/internal/models" + "github.com/interviewhub/backend/internal/services" +) + +const ( + maxLoginEmailBytes = 254 + maxLoginPasswordBytes = 72 + maxRegistrationNameBytes = 120 + maxRefreshTokenBytes = 512 +) + +type RegisterRequest struct { + Name string `json:"name"` + Email string `json:"email"` + Password string `json:"password"` + Role models.UserRole `json:"role,omitempty"` +} + +func (h *Handlers) Register(w http.ResponseWriter, r *http.Request) { + var req RegisterRequest + if err := decodeJSON(r, &req); err != nil { + SendError(w, http.StatusBadRequest, "invalid request payload") + return + } + name, email, validationMessage := normalizeRegistrationFields(req.Name, req.Email, req.Password) + if validationMessage != "" { + SendError(w, http.StatusBadRequest, validationMessage) + return + } + req.Name = name + req.Email = email + ipAllowed := h.authService.AllowAuthAttempt(authRateBucketKey("register-ip", clientAddress(r, h.cfg))) + identityAllowed := false + if ipAllowed { + identityAllowed = h.authService.AllowAuthAttempt(authRateBucketKey("register-identity", email)) + } + if !ipAllowed || !identityAllowed { + SendError(w, http.StatusTooManyRequests, "too many registration attempts; please try again later") + return + } + response, err := h.authService.Register(req.Name, req.Email, req.Password, req.Role) + if err != nil { + sendAuthServiceError(w, err) + return + } + SendSuccess(w, http.StatusCreated, response, "registration successful") +} + +func normalizeRegistrationFields(rawName, rawEmail, password string) (string, string, string) { + name := strings.TrimSpace(rawName) + if name == "" || len(name) > maxRegistrationNameBytes { + return "", "", "name must be between 1 and 120 characters" + } + email, validationMessage := normalizeLoginFields(rawEmail, password) + if validationMessage != "" { + return "", "", validationMessage + } + return name, email, "" +} + +type RefreshRequest struct { + RefreshToken string `json:"refreshToken"` +} + +func (h *Handlers) Refresh(w http.ResponseWriter, r *http.Request) { + var req RefreshRequest + if err := decodeJSON(r, &req); err != nil { + SendError(w, http.StatusBadRequest, "invalid request payload") + return + } + if req.RefreshToken == "" || len(req.RefreshToken) > maxRefreshTokenBytes { + SendError(w, http.StatusBadRequest, "invalid refresh token") + return + } + ipAllowed := h.authService.AllowRefreshSourceAttempt(authRateBucketKey("refresh-ip", clientAddress(r, h.cfg))) + tokenAllowed := false + if ipAllowed { + // Hashing keeps the bearer credential itself out of process-retained + // limiter keys while bounding repeated use from distributed clients. + tokenAllowed = h.authService.AllowRefreshCredentialAttempt(authRateBucketKey("refresh-token", req.RefreshToken)) + } + if !ipAllowed || !tokenAllowed { + SendError(w, http.StatusTooManyRequests, "too many refresh attempts; please try again later") + return + } + response, err := h.authService.Refresh(req.RefreshToken) + if err != nil { + sendAuthServiceError(w, err) + return + } + SendSuccess(w, http.StatusOK, response, "session refreshed") +} + +func (h *Handlers) Logout(w http.ResponseWriter, r *http.Request) { + var req RefreshRequest + if err := decodeJSON(r, &req); err != nil { + SendError(w, http.StatusBadRequest, "invalid request payload") + return + } + if req.RefreshToken == "" || len(req.RefreshToken) > maxRefreshTokenBytes { + SendError(w, http.StatusBadRequest, "invalid refresh token") + return + } + ipAllowed := h.authService.AllowRefreshSourceAttempt(authRateBucketKey("logout-ip", clientAddress(r, h.cfg))) + tokenAllowed := false + if ipAllowed { + // Logout accepts an opaque bearer credential without access-token + // authentication, so it needs the same source and credential admission + // bounds as refresh. The raw token never becomes a retained limiter key. + tokenAllowed = h.authService.AllowRefreshCredentialAttempt(authRateBucketKey("logout-token", req.RefreshToken)) + } + if !ipAllowed || !tokenAllowed { + SendError(w, http.StatusTooManyRequests, "too many logout attempts; please try again later") + return + } + if err := h.authService.Logout(req.RefreshToken); err != nil { + sendAuthServiceError(w, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +type LoginRequest struct { + Email string `json:"email"` + Password string `json:"password"` +} + +func (h *Handlers) Login(w http.ResponseWriter, r *http.Request) { + var req LoginRequest + if err := decodeJSON(r, &req); err != nil { + SendError(w, http.StatusBadRequest, "invalid request payload") + return + } + normalizedEmail, validationMessage := normalizeLoginFields(req.Email, req.Password) + if validationMessage != "" { + SendError(w, http.StatusBadRequest, validationMessage) + return + } + req.Email = normalizedEmail + + // Maintain independent budgets for credential spraying from one source and + // distributed attacks against one identity. Hashing bounds retained key size + // and ensures raw email addresses never live in the limiter map. + ipAllowed := h.authService.AllowAuthAttempt(authRateBucketKey("login-ip", clientAddress(r, h.cfg))) + identityAllowed := false + if ipAllowed { + identityAllowed = h.authService.AllowAuthAttempt(authRateBucketKey("login-identity", normalizedEmail)) + } + if !ipAllowed || !identityAllowed { + SendError(w, http.StatusTooManyRequests, "too many login attempts; please try again later") + return + } + response, err := h.authService.Login(req.Email, req.Password) + if err != nil { + sendAuthServiceError(w, err) + return + } + SendSuccess(w, http.StatusOK, response, "login successful") +} + +type ExchangeCodeRequest struct { + Code string `json:"code"` +} + +func (h *Handlers) ExchangeAuthCode(w http.ResponseWriter, r *http.Request) { + var req ExchangeCodeRequest + if err := decodeJSON(r, &req); err != nil { + SendError(w, http.StatusBadRequest, "invalid request payload") + return + } + if req.Code == "" || len(req.Code) > maxRefreshTokenBytes { + SendError(w, http.StatusBadRequest, "invalid authorization code") + return + } + if !h.authService.AllowAuthAttempt(authRateBucketKey("exchange-ip", clientAddress(r, h.cfg))) { + SendError(w, http.StatusTooManyRequests, "too many exchange attempts; please try again later") + return + } + response, err := h.authService.ExchangeAuthCode(req.Code) + if err != nil { + sendAuthServiceError(w, err) + return + } + SendSuccess(w, http.StatusOK, response, "session established") +} + +func normalizeLoginFields(rawEmail, password string) (string, string) { + if len(rawEmail) == 0 || len(rawEmail) > maxLoginEmailBytes { + return "", "a valid email address is required" + } + if len(password) == 0 { + return "", "password is required" + } + if len(password) > maxLoginPasswordBytes { + return "", "password must be at most 72 characters" + } + normalizedEmail := strings.ToLower(strings.TrimSpace(rawEmail)) + if len(normalizedEmail) == 0 || len(normalizedEmail) > maxLoginEmailBytes || services.ValidateEmail(normalizedEmail) != nil { + return "", "a valid email address is required" + } + return normalizedEmail, "" +} + +func authRateBucketKey(scope, value string) string { + digest := sha256.Sum256([]byte(value)) + return scope + ":" + hex.EncodeToString(digest[:]) +} + +func (h *Handlers) GetMe(w http.ResponseWriter, r *http.Request) { + claims, ok := claimsFromRequest(r) + if !ok { + SendError(w, http.StatusUnauthorized, "authentication required") + return + } + user, err := h.userService.GetProfile(claims.ID) + if err != nil { + switch { + case errors.Is(err, services.ErrNotFound): + SendError(w, http.StatusNotFound, "user not found") + case errors.Is(err, services.ErrDependencyUnavailable): + SendError(w, http.StatusServiceUnavailable, "account service temporarily unavailable") + default: + SendError(w, http.StatusInternalServerError, "unable to load account") + } + return + } + SendSuccess(w, http.StatusOK, user) +} + +func (h *Handlers) GetMyProgress(w http.ResponseWriter, r *http.Request) { + claims, ok := claimsFromRequest(r) + if !ok { + SendError(w, http.StatusUnauthorized, "authentication required") + return + } + progress, err := h.userService.GetProgress(claims.ID) + if err != nil { + SendError(w, http.StatusServiceUnavailable, "progress is temporarily unavailable") + return + } + SendSuccess(w, http.StatusOK, progress) +} + +func sendAuthServiceError(w http.ResponseWriter, err error) { + switch { + case errors.Is(err, services.ErrInvalidInput): + SendError(w, http.StatusBadRequest, services.PublicErrorMessage(err, "invalid authentication request")) + case errors.Is(err, services.ErrInvalidCredentials): + SendError(w, http.StatusUnauthorized, services.PublicErrorMessage(err, "invalid credentials")) + case errors.Is(err, services.ErrConflict): + SendError(w, http.StatusConflict, services.PublicErrorMessage(err, "account already exists")) + case errors.Is(err, services.ErrDependencyUnavailable): + SendError(w, http.StatusServiceUnavailable, "authentication service temporarily unavailable") + default: + SendError(w, http.StatusInternalServerError, "unable to complete authentication request") + } +} diff --git a/interviewhub-backend-go/internal/handlers/auth_workspace_error_contracts_test.go b/interviewhub-backend-go/internal/handlers/auth_workspace_error_contracts_test.go new file mode 100644 index 00000000..3047794f --- /dev/null +++ b/interviewhub-backend-go/internal/handlers/auth_workspace_error_contracts_test.go @@ -0,0 +1,447 @@ +package handlers + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/go-chi/chi/v5" + "github.com/interviewhub/backend/internal/config" + "github.com/interviewhub/backend/internal/models" + "github.com/interviewhub/backend/internal/services" + "github.com/interviewhub/backend/internal/store" +) + +type httpErrorRepository struct { + store.Repository + createUserErr error + lookupUserByIDErr error + lookupUserByEmailErr error + consumeAuthCodeErr error + rotateRefreshErr error + revokeRefreshErr error + problemExistsErr error + lookupWorkspaceErr error + saveWorkspaceErr error + deleteWorkspaceErr error +} + +func (r *httpErrorRepository) CreateUser(user models.User) (models.User, error) { + if r.createUserErr != nil { + return models.User{}, r.createUserErr + } + return r.Repository.CreateUser(user) +} + +func (r *httpErrorRepository) LookupUserByID(id string) (models.User, bool, error) { + if r.lookupUserByIDErr != nil { + return models.User{}, false, r.lookupUserByIDErr + } + return r.Repository.(store.ErrorAwareUserLookup).LookupUserByID(id) +} + +func (r *httpErrorRepository) LookupUserByEmail(email string) (models.User, bool, error) { + if r.lookupUserByEmailErr != nil { + return models.User{}, false, r.lookupUserByEmailErr + } + return r.Repository.(store.ErrorAwareUserLookup).LookupUserByEmail(email) +} + +func (r *httpErrorRepository) ConsumeAuthCodeWithError(codeHash string) (string, bool, error) { + if r.consumeAuthCodeErr != nil { + return "", false, r.consumeAuthCodeErr + } + return r.Repository.(store.ErrorAwareAuthCodeConsumer).ConsumeAuthCodeWithError(codeHash) +} + +func (r *httpErrorRepository) RotateRefreshToken(tokenHash string, replacement models.RefreshToken, now time.Time) (models.RefreshToken, store.RefreshTokenRotationOutcome, error) { + if r.rotateRefreshErr != nil { + return models.RefreshToken{}, "", r.rotateRefreshErr + } + return r.Repository.RotateRefreshToken(tokenHash, replacement, now) +} + +func (r *httpErrorRepository) RevokeRefreshTokenFamilyByHash(tokenHash string, now time.Time) (bool, error) { + if r.revokeRefreshErr != nil { + return false, r.revokeRefreshErr + } + return r.Repository.(store.ErrorAwareRefreshTokenRevoker).RevokeRefreshTokenFamilyByHash(tokenHash, now) +} + +func (r *httpErrorRepository) ProblemExists(id string) (bool, error) { + if r.problemExistsErr != nil { + return false, r.problemExistsErr + } + return r.Repository.(store.ErrorAwareProblemExistence).ProblemExists(id) +} + +func (r *httpErrorRepository) LookupWorkspace(userID, problemID string, language models.Language) (models.Workspace, bool, error) { + if r.lookupWorkspaceErr != nil { + return models.Workspace{}, false, r.lookupWorkspaceErr + } + return r.Repository.(store.ErrorAwareWorkspaceLookup).LookupWorkspace(userID, problemID, language) +} + +func (r *httpErrorRepository) SaveWorkspace(workspace models.Workspace) (models.Workspace, error) { + if r.saveWorkspaceErr != nil { + return models.Workspace{}, r.saveWorkspaceErr + } + return r.Repository.SaveWorkspace(workspace) +} + +func (r *httpErrorRepository) DeleteWorkspace(userID, problemID string, language models.Language) (bool, error) { + if r.deleteWorkspaceErr != nil { + return false, r.deleteWorkspaceErr + } + return r.Repository.DeleteWorkspace(userID, problemID, language) +} + +func authErrorTestConfig() *config.Config { + return &config.Config{ + JWTSecret: "handler-error-contract-secret-long-enough", + AccessTokenTTL: 15 * time.Minute, + RefreshTokenTTL: time.Hour, + } +} + +func errorContractHandlers(repository store.Repository) *Handlers { + cfg := authErrorTestConfig() + return &Handlers{ + authService: services.NewAuthService(repository, cfg), + workspaceService: services.NewWorkspaceService(repository), + } +} + +func TestAuthHandlersReturnGenericServiceUnavailableForPersistenceFailures(t *testing.T) { + injected := errors.New("dial tcp db.internal:5432: password=must-not-leak") + tests := []struct { + name string + repository *httpErrorRepository + body string + invoke func(*Handlers, http.ResponseWriter, *http.Request) + }{ + { + name: "register write", + repository: &httpErrorRepository{ + Repository: store.NewMemoryStore(), + createUserErr: injected, + }, + body: `{"name":"New User","email":"new@example.com","password":"password123"}`, + invoke: (*Handlers).Register, + }, + { + name: "login read", + repository: &httpErrorRepository{ + Repository: store.NewMemoryStore(), + lookupUserByEmailErr: injected, + }, + body: `{"email":"candidate@google.com","password":"pass123"}`, + invoke: (*Handlers).Login, + }, + { + name: "refresh rotation", + repository: &httpErrorRepository{ + Repository: store.NewMemoryStore(), + rotateRefreshErr: injected, + }, + body: `{"refreshToken":"opaque-token"}`, + invoke: (*Handlers).Refresh, + }, + { + name: "handoff consumption", + repository: &httpErrorRepository{ + Repository: store.NewMemoryStore(), + consumeAuthCodeErr: injected, + }, + body: `{"code":"opaque-code"}`, + invoke: (*Handlers).ExchangeAuthCode, + }, + { + name: "logout revocation", + repository: &httpErrorRepository{ + Repository: store.NewMemoryStore(), + revokeRefreshErr: injected, + }, + body: `{"refreshToken":"opaque-token"}`, + invoke: (*Handlers).Logout, + }, + } + + for _, testCase := range tests { + t.Run(testCase.name, func(t *testing.T) { + handler := errorContractHandlers(testCase.repository) + request := httptest.NewRequest(http.MethodPost, "/api/auth", bytes.NewBufferString(testCase.body)) + response := httptest.NewRecorder() + testCase.invoke(handler, response, request) + assertUnavailableWithoutLeak(t, response, "authentication service temporarily unavailable", injected.Error()) + }) + } +} + +func TestAuthHandlersPreserveExpectedClientStatuses(t *testing.T) { + handler := errorContractHandlers(store.NewMemoryStore()) + tests := []struct { + name string + body string + status int + invoke func(*Handlers, http.ResponseWriter, *http.Request) + }{ + {name: "duplicate account", body: `{"name":"Candidate","email":"candidate@google.com","password":"password123"}`, status: http.StatusConflict, invoke: (*Handlers).Register}, + {name: "invalid credentials", body: `{"email":"missing@example.com","password":"password123"}`, status: http.StatusUnauthorized, invoke: (*Handlers).Login}, + {name: "missing refresh token", body: `{}`, status: http.StatusBadRequest, invoke: (*Handlers).Refresh}, + {name: "missing handoff code", body: `{}`, status: http.StatusBadRequest, invoke: (*Handlers).ExchangeAuthCode}, + } + + for _, testCase := range tests { + t.Run(testCase.name, func(t *testing.T) { + request := httptest.NewRequest(http.MethodPost, "/api/auth", bytes.NewBufferString(testCase.body)) + response := httptest.NewRecorder() + testCase.invoke(handler, response, request) + if response.Code != testCase.status { + t.Fatalf("status=%d body=%s, want %d", response.Code, response.Body.String(), testCase.status) + } + }) + } +} + +func TestLoginBoundsAndNormalizesBeforeRateLimiting(t *testing.T) { + handler := errorContractHandlers(store.NewMemoryStore()) + oversizedEmail := strings.Repeat("a", maxLoginEmailBytes+1) + "@example.com" + + // Invalid requests must not allocate limiter identities or consume the + // source-IP budget. A valid login after repeated oversized requests proves + // validation happens before admission. + for attempt := 0; attempt < 20; attempt++ { + request := httptest.NewRequest(http.MethodPost, "/api/auth/login", bytes.NewBufferString( + fmt.Sprintf(`{"email":%q,"password":"password123"}`, oversizedEmail), + )) + response := httptest.NewRecorder() + handler.Login(response, request) + if response.Code != http.StatusBadRequest { + t.Fatalf("oversized attempt %d status=%d body=%s, want 400", attempt, response.Code, response.Body.String()) + } + } + + valid := httptest.NewRequest(http.MethodPost, "/api/auth/login", bytes.NewBufferString( + `{"email":" CANDIDATE@GOOGLE.COM ","password":"pass123"}`, + )) + validResponse := httptest.NewRecorder() + handler.Login(validResponse, valid) + if validResponse.Code != http.StatusOK { + t.Fatalf("normalized login status=%d body=%s, want 200", validResponse.Code, validResponse.Body.String()) + } + + oversizedPassword := httptest.NewRequest(http.MethodPost, "/api/auth/login", bytes.NewBufferString( + fmt.Sprintf(`{"email":"candidate@google.com","password":%q}`, strings.Repeat("x", maxLoginPasswordBytes+1)), + )) + oversizedPasswordResponse := httptest.NewRecorder() + handler.Login(oversizedPasswordResponse, oversizedPassword) + if oversizedPasswordResponse.Code != http.StatusBadRequest { + t.Fatalf("oversized password status=%d body=%s, want 400", oversizedPasswordResponse.Code, oversizedPasswordResponse.Body.String()) + } +} + +func TestLoginUsesIndependentIPAndIdentityBudgets(t *testing.T) { + t.Run("source IP spray", func(t *testing.T) { + handler := errorContractHandlers(store.NewMemoryStore()) + for attempt := 0; attempt <= 10; attempt++ { + body := fmt.Sprintf(`{"email":"missing-%d@example.com","password":"password123"}`, attempt) + request := httptest.NewRequest(http.MethodPost, "/api/auth/login", bytes.NewBufferString(body)) + request.RemoteAddr = "192.0.2.10:4567" + response := httptest.NewRecorder() + handler.Login(response, request) + want := http.StatusUnauthorized + if attempt == 10 { + want = http.StatusTooManyRequests + } + if response.Code != want { + t.Fatalf("attempt %d status=%d body=%s, want %d", attempt, response.Code, response.Body.String(), want) + } + } + }) + + t.Run("distributed identity attack", func(t *testing.T) { + handler := errorContractHandlers(store.NewMemoryStore()) + for attempt := 0; attempt <= 10; attempt++ { + request := httptest.NewRequest(http.MethodPost, "/api/auth/login", bytes.NewBufferString( + `{"email":"target@example.com","password":"password123"}`, + )) + request.RemoteAddr = fmt.Sprintf("192.0.2.%d:4567", attempt+20) + response := httptest.NewRecorder() + handler.Login(response, request) + want := http.StatusUnauthorized + if attempt == 10 { + want = http.StatusTooManyRequests + } + if response.Code != want { + t.Fatalf("attempt %d status=%d body=%s, want %d", attempt, response.Code, response.Body.String(), want) + } + } + }) +} + +func TestAuthRateBucketKeyIsFixedSizeAndOpaque(t *testing.T) { + normalized, message := normalizeLoginFields(" Person@Example.COM ", "password123") + if message != "" || normalized != "person@example.com" { + t.Fatalf("normalizeLoginFields=(%q,%q)", normalized, message) + } + tests := []struct { + scope string + value string + }{ + {scope: "login-identity", value: normalized}, + {scope: "logout-token", value: "opaque-refresh-credential-that-must-not-be-retained"}, + } + for _, testCase := range tests { + key := authRateBucketKey(testCase.scope, testCase.value) + if strings.Contains(key, testCase.value) { + t.Fatalf("%s rate-limit key retained raw value: %q", testCase.scope, key) + } + wantLength := len(testCase.scope) + 1 + sha256.Size*2 + if len(key) != wantLength { + t.Fatalf("%s rate-limit key length=%d, want %d", testCase.scope, len(key), wantLength) + } + } +} + +func TestWorkspaceHandlersReturnGenericServiceUnavailableForPersistenceFailures(t *testing.T) { + injected := errors.New("workspace table corrupt: private row payload") + tests := []struct { + name string + repository *httpErrorRepository + method string + body string + invoke func(*Handlers, http.ResponseWriter, *http.Request) + }{ + { + name: "problem existence", + repository: &httpErrorRepository{ + Repository: store.NewMemoryStore(), + problemExistsErr: injected, + }, + method: http.MethodGet, + invoke: (*Handlers).GetWorkspace, + }, + { + name: "workspace read", + repository: &httpErrorRepository{ + Repository: store.NewMemoryStore(), + lookupWorkspaceErr: injected, + }, + method: http.MethodGet, + invoke: (*Handlers).GetWorkspace, + }, + { + name: "workspace write", + repository: &httpErrorRepository{ + Repository: store.NewMemoryStore(), + saveWorkspaceErr: injected, + }, + method: http.MethodPut, + body: validWorkspaceJSON(t), + invoke: (*Handlers).SaveWorkspace, + }, + { + name: "workspace delete", + repository: &httpErrorRepository{ + Repository: store.NewMemoryStore(), + deleteWorkspaceErr: injected, + }, + method: http.MethodDelete, + invoke: (*Handlers).DeleteWorkspace, + }, + } + + for _, testCase := range tests { + t.Run(testCase.name, func(t *testing.T) { + handler := errorContractHandlers(testCase.repository) + request := authenticatedWorkspaceRequest(testCase.method, "parking-lot", "go", testCase.body) + response := httptest.NewRecorder() + testCase.invoke(handler, response, request) + assertUnavailableWithoutLeak(t, response, "workspace service temporarily unavailable", injected.Error()) + }) + } +} + +func TestWorkspaceHandlersPreserveExpectedClientStatuses(t *testing.T) { + handler := errorContractHandlers(store.NewMemoryStore()) + tests := []struct { + name string + problemID string + language string + status int + invoke func(*Handlers, http.ResponseWriter, *http.Request) + }{ + {name: "unsupported language", problemID: "parking-lot", language: "ruby", status: http.StatusBadRequest, invoke: (*Handlers).GetWorkspace}, + {name: "missing problem", problemID: "missing-problem", language: "go", status: http.StatusNotFound, invoke: (*Handlers).GetWorkspace}, + {name: "missing workspace", problemID: "parking-lot", language: "go", status: http.StatusNotFound, invoke: (*Handlers).DeleteWorkspace}, + } + + for _, testCase := range tests { + t.Run(testCase.name, func(t *testing.T) { + request := authenticatedWorkspaceRequest(http.MethodGet, testCase.problemID, testCase.language, "") + response := httptest.NewRecorder() + testCase.invoke(handler, response, request) + if response.Code != testCase.status { + t.Fatalf("status=%d body=%s, want %d", response.Code, response.Body.String(), testCase.status) + } + }) + } +} + +func authenticatedWorkspaceRequest(method, problemID, language, body string) *http.Request { + request := httptest.NewRequest(method, "/api/problems/"+problemID+"/workspace?language="+language, strings.NewReader(body)) + routeContext := chi.NewRouteContext() + routeContext.URLParams.Add("id", problemID) + ctx := context.WithValue(request.Context(), chi.RouteCtxKey, routeContext) + ctx = context.WithValue(ctx, UserContextKey, &services.JWTClaims{ID: "usr_cand_01"}) + return request.WithContext(ctx) +} + +func validWorkspaceJSON(t *testing.T) string { + t.Helper() + workspace := models.Workspace{ + Language: models.LangGo, + ActiveFileID: "main", + Files: []models.FileNode{{ + ID: "src", + Name: "src", + Path: "src", + IsDirectory: true, + Children: []models.FileNode{{ + ID: "main", + Name: "main.go", + Path: "src/main.go", + Language: models.LangGo, + Content: "package main", + }}, + }}, + } + payload, err := json.Marshal(workspace) + if err != nil { + t.Fatal(err) + } + return string(payload) +} + +func assertUnavailableWithoutLeak(t *testing.T, response *httptest.ResponseRecorder, expectedMessage, secret string) { + t.Helper() + if response.Code != http.StatusServiceUnavailable { + t.Fatalf("status=%d body=%s, want 503", response.Code, response.Body.String()) + } + body := response.Body.String() + if !strings.Contains(body, expectedMessage) { + t.Fatalf("body=%s, want generic message %q", body, expectedMessage) + } + if strings.Contains(body, secret) || strings.Contains(body, "db.internal") || strings.Contains(body, "private row") { + t.Fatalf("response leaked persistence details: %s", body) + } +} diff --git a/interviewhub-backend-go/internal/handlers/execution_handlers.go b/interviewhub-backend-go/internal/handlers/execution_handlers.go new file mode 100644 index 00000000..bbc64df6 --- /dev/null +++ b/interviewhub-backend-go/internal/handlers/execution_handlers.go @@ -0,0 +1,156 @@ +package handlers + +import ( + "errors" + "net/http" + "strconv" + "time" + + "github.com/go-chi/chi/v5" + "github.com/interviewhub/backend/internal/models" + "github.com/interviewhub/backend/internal/services" +) + +type executionResponse struct { + ExecutionID string `json:"executionId"` + Kind models.ExecutionKind `json:"kind"` + Status models.ExecutionStatus `json:"status"` + Result *models.ExecutionResult `json:"result,omitempty"` + Submission *models.Submission `json:"submission,omitempty"` + Error string `json:"error,omitempty"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +func (h *Handlers) RunCode(w http.ResponseWriter, r *http.Request) { + h.queueExecution(w, r, models.ExecutionKindRun) +} + +func (h *Handlers) SubmitCode(w http.ResponseWriter, r *http.Request) { + h.queueExecution(w, r, models.ExecutionKindSubmit) +} + +func (h *Handlers) queueExecution(w http.ResponseWriter, r *http.Request, kind models.ExecutionKind) { + claims, ok := claimsFromRequest(r) + if !ok { + SendError(w, http.StatusUnauthorized, "authentication required") + return + } + if !h.cfg.ExecutionEnabled || h.cfg.ExecutionRunner == "disabled" { + SendError(w, http.StatusServiceUnavailable, "code execution is disabled") + return + } + var request models.ExecutionRequest + if err := decodeJSON(r, &request); err != nil { + SendError(w, http.StatusBadRequest, "invalid execution payload: "+err.Error()) + return + } + // Tests are catalog-owned; clients may submit source and stdin only. + request.TestCases = nil + job, err := h.executionService.QueueExecution(request, kind, claims.ID) + if err != nil { + switch { + case errors.Is(err, services.ErrExecutionQueueFull): + w.Header().Set("Retry-After", "1") + SendError(w, http.StatusTooManyRequests, "execution queue is full; retry shortly") + case errors.Is(err, services.ErrExecutionUserSaturated): + w.Header().Set("Retry-After", "1") + SendError(w, http.StatusTooManyRequests, "too many active executions for this account") + case errors.Is(err, services.ErrExecutionDisabled), + errors.Is(err, services.ErrExecutionServiceClosed), + errors.Is(err, services.ErrSubmissionNotConfigured): + SendError(w, http.StatusServiceUnavailable, err.Error()) + case errors.Is(err, services.ErrDependencyUnavailable): + SendError(w, http.StatusServiceUnavailable, "execution dependencies are temporarily unavailable") + default: + SendError(w, http.StatusBadRequest, err.Error()) + } + return + } + SendSuccess(w, http.StatusAccepted, map[string]any{ + "executionId": job.ID, + "status": job.Status, + "kind": job.Kind, + }, "execution queued") +} + +func (h *Handlers) GetExecution(w http.ResponseWriter, r *http.Request) { + claims, authenticated := claimsFromRequest(r) + if !authenticated { + SendError(w, http.StatusUnauthorized, "authentication required") + return + } + job, ok := h.executionService.GetExecution(chi.URLParam(r, "id")) + if !ok || (claims.Role != models.RoleAdmin && job.UserID != claims.ID) { + // Deliberately return 404 so callers cannot enumerate other users' jobs. + SendError(w, http.StatusNotFound, "execution not found") + return + } + SendSuccess(w, http.StatusOK, executionResponse{ + ExecutionID: job.ID, + Kind: job.Kind, + Status: job.Status, + Result: job.Result, + Submission: job.Submission, + Error: job.Error, + CreatedAt: job.CreatedAt, + UpdatedAt: job.UpdatedAt, + }) +} + +func (h *Handlers) ListSubmissions(w http.ResponseWriter, r *http.Request) { + claims, ok := claimsFromRequest(r) + if !ok { + SendError(w, http.StatusUnauthorized, "authentication required") + return + } + userID := claims.ID + if claims.Role == models.RoleAdmin { + userID = "" + } + limit := 0 + if rawLimit := r.URL.Query().Get("limit"); rawLimit != "" { + parsed, err := strconv.Atoi(rawLimit) + if err != nil { + SendError(w, http.StatusBadRequest, "limit must be an integer") + return + } + limit = parsed + } + page, err := h.submissionService.ListSubmissions(userID, services.SubmissionListOptions{ + ProblemID: r.URL.Query().Get("problemId"), + Cursor: r.URL.Query().Get("cursor"), + Limit: limit, + }) + if err != nil { + if errors.Is(err, services.ErrInvalidInput) { + SendError(w, http.StatusBadRequest, services.PublicErrorMessage(err, "invalid submission history request")) + } else { + SendError(w, http.StatusServiceUnavailable, "submission history is temporarily unavailable") + } + return + } + SendSuccess(w, http.StatusOK, page) +} + +func (h *Handlers) GetSubmission(w http.ResponseWriter, r *http.Request) { + claims, ok := claimsFromRequest(r) + if !ok { + SendError(w, http.StatusUnauthorized, "authentication required") + return + } + ownerUserID := claims.ID + if claims.Role == models.RoleAdmin { + ownerUserID = "" + } + submission, found, err := h.submissionService.GetSubmission(chi.URLParam(r, "id"), ownerUserID) + if err != nil { + SendError(w, http.StatusServiceUnavailable, "submission history is temporarily unavailable") + return + } + if !found { + SendError(w, http.StatusNotFound, "submission not found") + return + } + SendSuccess(w, http.StatusOK, submission) +} diff --git a/interviewhub-backend-go/internal/handlers/handlers.go b/interviewhub-backend-go/internal/handlers/handlers.go index 33bccf19..a55a9a5c 100644 --- a/interviewhub-backend-go/internal/handlers/handlers.go +++ b/interviewhub-backend-go/internal/handlers/handlers.go @@ -1,16 +1,20 @@ package handlers import ( - "encoding/json" - "net/http" - "strings" + "context" - "github.com/go-chi/chi/v5" "github.com/interviewhub/backend/internal/config" - "github.com/interviewhub/backend/internal/models" "github.com/interviewhub/backend/internal/services" ) +// ReadinessChecker is implemented by infrastructure dependencies that must be +// reachable before this API should receive traffic. +type ReadinessChecker interface { + Ping(context.Context) error +} + +// Handlers is the HTTP composition root. Feature handlers stay split by +// resource while sharing the same explicitly injected application services. type Handlers struct { authService *services.AuthService oauthService *services.OAuthService @@ -21,401 +25,35 @@ type Handlers struct { userService *services.UserService workspaceService *services.WorkspaceService cfg *config.Config + readiness ReadinessChecker } func NewHandlers( auth *services.AuthService, oauth *services.OAuthService, - prob *services.ProblemService, - exec *services.ExecutionService, - sub *services.SubmissionService, + problems *services.ProblemService, + executions *services.ExecutionService, + submissions *services.SubmissionService, ai *services.AIService, - user *services.UserService, - workspace *services.WorkspaceService, + users *services.UserService, + workspaces *services.WorkspaceService, cfg *config.Config, + readiness ...ReadinessChecker, ) *Handlers { + var checker ReadinessChecker + if len(readiness) > 0 { + checker = readiness[0] + } return &Handlers{ authService: auth, oauthService: oauth, - problemService: prob, - executionService: exec, - submissionService: sub, + problemService: problems, + executionService: executions, + submissionService: submissions, aiService: ai, - userService: user, - workspaceService: workspace, + userService: users, + workspaceService: workspaces, cfg: cfg, + readiness: checker, } } - -// HealthCheck returns service status -func (h *Handlers) HealthCheck(w http.ResponseWriter, r *http.Request) { - SendSuccess(w, http.StatusOK, map[string]any{ - "status": "healthy", - "service": "InterviewHub Go Engine", - "version": "1.0.0", - "queue": h.executionService.Metrics(), - }, "InterviewHub LLD Backend is running smoothly") -} - -func (h *Handlers) Readiness(w http.ResponseWriter, r *http.Request) { - SendSuccess(w, http.StatusOK, map[string]any{ - "ready": h.executionService.Ready(), - "service": "InterviewHub Go Engine", - }) -} - -func (h *Handlers) Metrics(w http.ResponseWriter, r *http.Request) { - SendSuccess(w, http.StatusOK, h.executionService.Metrics()) -} - -// --- AUTH HANDLERS --- - -type RegisterRequest struct { - Name string `json:"name"` - Email string `json:"email"` - Password string `json:"password"` - Role models.UserRole `json:"role"` -} - -func (h *Handlers) Register(w http.ResponseWriter, r *http.Request) { - var req RegisterRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - SendError(w, http.StatusBadRequest, "Invalid request payload") - return - } - - clientKey := "register:" + r.RemoteAddr - if !h.authService.AllowAuthAttempt(clientKey) { - SendError(w, http.StatusTooManyRequests, "Too many registration attempts. Please try again later.") - return - } - - res, err := h.authService.Register(req.Name, req.Email, req.Password, req.Role) - if err != nil { - SendError(w, http.StatusBadRequest, err.Error()) - return - } - - SendSuccess(w, http.StatusCreated, res, "Registration successful") -} - -type RefreshRequest struct { - RefreshToken string `json:"refreshToken"` -} - -func (h *Handlers) Refresh(w http.ResponseWriter, r *http.Request) { - var req RefreshRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - SendError(w, http.StatusBadRequest, "Invalid request payload") - return - } - res, err := h.authService.Refresh(req.RefreshToken) - if err != nil { - SendError(w, http.StatusUnauthorized, err.Error()) - return - } - SendSuccess(w, http.StatusOK, res, "Session refreshed") -} - -func (h *Handlers) Logout(w http.ResponseWriter, r *http.Request) { - var req RefreshRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - SendError(w, http.StatusBadRequest, "Invalid request payload") - return - } - h.authService.Logout(req.RefreshToken) - w.WriteHeader(http.StatusNoContent) -} - -type LoginRequest struct { - Email string `json:"email"` - Password string `json:"password"` -} - -func (h *Handlers) Login(w http.ResponseWriter, r *http.Request) { - var req LoginRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - SendError(w, http.StatusBadRequest, "Invalid request payload") - return - } - - clientKey := "login:" + r.RemoteAddr + ":" + strings.ToLower(strings.TrimSpace(req.Email)) - if !h.authService.AllowAuthAttempt(clientKey) { - SendError(w, http.StatusTooManyRequests, "Too many login attempts. Please try again later.") - return - } - - res, err := h.authService.Login(req.Email, req.Password) - if err != nil { - SendError(w, http.StatusUnauthorized, err.Error()) - return - } - - SendSuccess(w, http.StatusOK, res, "Login successful") -} - -type ExchangeCodeRequest struct { - Code string `json:"code"` -} - -// ExchangeAuthCode turns a one-time OAuth handoff code into a session pair. -func (h *Handlers) ExchangeAuthCode(w http.ResponseWriter, r *http.Request) { - var req ExchangeCodeRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - SendError(w, http.StatusBadRequest, "Invalid request payload") - return - } - clientKey := "exchange:" + r.RemoteAddr - if !h.authService.AllowAuthAttempt(clientKey) { - SendError(w, http.StatusTooManyRequests, "Too many exchange attempts. Please try again later.") - return - } - res, err := h.authService.ExchangeAuthCode(req.Code) - if err != nil { - SendError(w, http.StatusUnauthorized, err.Error()) - return - } - SendSuccess(w, http.StatusOK, res, "Session established") -} - -func (h *Handlers) GetMe(w http.ResponseWriter, r *http.Request) { - claims, ok := r.Context().Value(UserContextKey).(*services.JWTClaims) - if !ok { - SendError(w, http.StatusUnauthorized, "Unauthorized") - return - } - - user, ok := h.userService.GetProfile(claims.ID) - if !ok { - SendError(w, http.StatusNotFound, "User not found") - return - } - SendSuccess(w, http.StatusOK, user) -} - -func (h *Handlers) GetMyProgress(w http.ResponseWriter, r *http.Request) { - claims := r.Context().Value(UserContextKey).(*services.JWTClaims) - SendSuccess(w, http.StatusOK, h.userService.GetProgress(claims.ID)) -} - -// --- PROBLEM HANDLERS --- - -func (h *Handlers) ListProblems(w http.ResponseWriter, r *http.Request) { - q := r.URL.Query() - filter := services.ProblemFilter{ - Difficulty: q.Get("difficulty"), - Company: q.Get("company"), - Search: q.Get("search"), - } - - problems := h.problemService.GetProblems(filter) - SendSuccess(w, http.StatusOK, problems) -} - -func (h *Handlers) GetProblem(w http.ResponseWriter, r *http.Request) { - id := chi.URLParam(r, "id") - problem, err := h.problemService.GetProblemByID(id) - if err != nil { - SendError(w, http.StatusNotFound, err.Error()) - return - } - - SendSuccess(w, http.StatusOK, problem) -} - -func (h *Handlers) CreateProblem(w http.ResponseWriter, r *http.Request) { - var p models.Problem - if err := json.NewDecoder(r.Body).Decode(&p); err != nil { - SendError(w, http.StatusBadRequest, "Invalid problem payload: "+err.Error()) - return - } - - created, err := h.problemService.CreateProblem(p) - if err != nil { - SendError(w, http.StatusBadRequest, err.Error()) - return - } - - SendSuccess(w, http.StatusCreated, created, "Problem created successfully") -} - -func (h *Handlers) UpdateProblem(w http.ResponseWriter, r *http.Request) { - id := chi.URLParam(r, "id") - var p models.Problem - if err := json.NewDecoder(r.Body).Decode(&p); err != nil { - SendError(w, http.StatusBadRequest, "Invalid problem payload: "+err.Error()) - return - } - - updated, err := h.problemService.UpdateProblem(id, p) - if err != nil { - SendError(w, http.StatusBadRequest, err.Error()) - return - } - - SendSuccess(w, http.StatusOK, updated, "Problem updated successfully") -} - -func (h *Handlers) DeleteProblem(w http.ResponseWriter, r *http.Request) { - id := chi.URLParam(r, "id") - if err := h.problemService.DeleteProblem(id); err != nil { - SendError(w, http.StatusNotFound, err.Error()) - return - } - - SendSuccess(w, http.StatusOK, map[string]string{"id": id}, "Problem deleted successfully") -} - -func (h *Handlers) ResetDefaultProblems(w http.ResponseWriter, r *http.Request) { - defaults := h.problemService.ResetToDefaults() - SendSuccess(w, http.StatusOK, defaults, "All problems reset to factory seed state") -} - -// --- WORKSPACE HANDLERS --- - -func (h *Handlers) GetWorkspace(w http.ResponseWriter, r *http.Request) { - claims := r.Context().Value(UserContextKey).(*services.JWTClaims) - problemID := chi.URLParam(r, "id") - language := services.NormalizeLanguage(r.URL.Query().Get("language")) - workspace, err := h.workspaceService.Get(claims.ID, problemID, language) - if err != nil { - SendError(w, http.StatusNotFound, err.Error()) - return - } - SendSuccess(w, http.StatusOK, workspace) -} - -func (h *Handlers) SaveWorkspace(w http.ResponseWriter, r *http.Request) { - claims := r.Context().Value(UserContextKey).(*services.JWTClaims) - problemID := chi.URLParam(r, "id") - var workspace models.Workspace - if err := json.NewDecoder(r.Body).Decode(&workspace); err != nil { - SendError(w, http.StatusBadRequest, "Invalid workspace payload") - return - } - saved, err := h.workspaceService.Save(claims.ID, problemID, workspace) - if err != nil { - status := http.StatusBadRequest - if err.Error() == "problem not found" { - status = http.StatusNotFound - } - SendError(w, status, err.Error()) - return - } - SendSuccess(w, http.StatusOK, saved, "Workspace saved") -} - -func (h *Handlers) DeleteWorkspace(w http.ResponseWriter, r *http.Request) { - claims := r.Context().Value(UserContextKey).(*services.JWTClaims) - deleted := h.workspaceService.Delete(claims.ID, chi.URLParam(r, "id"), services.NormalizeLanguage(r.URL.Query().Get("language"))) - if !deleted { - SendError(w, http.StatusNotFound, "Workspace not found") - return - } - w.WriteHeader(http.StatusNoContent) -} - -// --- EXECUTION & SUBMISSION HANDLERS --- - -func (h *Handlers) RunCode(w http.ResponseWriter, r *http.Request) { - var req models.ExecutionRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - SendError(w, http.StatusBadRequest, "Invalid execution payload: "+err.Error()) - return - } - // Tests are catalog-owned. A caller may supply source files, never the - // assertions used to decide a result. - req.TestCases = nil - - job, err := h.executionService.QueueExecution(req, models.ExecutionKindRun, "") - if err != nil { - SendError(w, http.StatusInternalServerError, err.Error()) - return - } - SendSuccess(w, http.StatusAccepted, map[string]any{"executionId": job.ID, "status": job.Status, "kind": job.Kind}, "Execution queued") -} - -func (h *Handlers) SubmitCode(w http.ResponseWriter, r *http.Request) { - var req models.ExecutionRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - SendError(w, http.StatusBadRequest, "Invalid submission payload: "+err.Error()) - return - } - // Submissions always use server-owned tests, including hidden cases once - // the production judge adapter is introduced. - req.TestCases = nil - - userID := "anonymous_candidate" - if claims, ok := r.Context().Value(UserContextKey).(*services.JWTClaims); ok { - userID = claims.ID - } - - job, err := h.executionService.QueueExecution(req, models.ExecutionKindSubmit, userID) - if err != nil { - SendError(w, http.StatusInternalServerError, err.Error()) - return - } - SendSuccess(w, http.StatusAccepted, map[string]any{"executionId": job.ID, "status": job.Status, "kind": job.Kind}, "Submission queued for evaluation") -} - -func (h *Handlers) GetExecution(w http.ResponseWriter, r *http.Request) { - job, ok := h.executionService.GetExecution(chi.URLParam(r, "id")) - if !ok { - SendError(w, http.StatusNotFound, "Execution not found") - return - } - SendSuccess(w, http.StatusOK, map[string]any{ - "executionId": job.ID, - "kind": job.Kind, - "status": job.Status, - "result": job.Result, - "createdAt": job.CreatedAt, - "updatedAt": job.UpdatedAt, - }) -} - -func (h *Handlers) ListSubmissions(w http.ResponseWriter, r *http.Request) { - problemID := r.URL.Query().Get("problemId") - userID := "" - if claims, ok := r.Context().Value(UserContextKey).(*services.JWTClaims); ok { - if claims.Role != models.RoleAdmin { - userID = claims.ID - } - } - - submissions := h.submissionService.GetUserSubmissions(userID, problemID) - SendSuccess(w, http.StatusOK, submissions) -} - -func (h *Handlers) GetSubmission(w http.ResponseWriter, r *http.Request) { - claims := r.Context().Value(UserContextKey).(*services.JWTClaims) - submission, ok := h.submissionService.GetSubmission(chi.URLParam(r, "id")) - if !ok || (claims.Role != models.RoleAdmin && submission.UserID != claims.ID) { - SendError(w, http.StatusNotFound, "Submission not found") - return - } - SendSuccess(w, http.StatusOK, submission) -} - -// --- AI HANDLER --- - -type AIChatRequest struct { - ProblemID string `json:"problemId"` - Prompt string `json:"prompt"` - CodeSnippet string `json:"codeSnippet"` -} - -func (h *Handlers) AIChat(w http.ResponseWriter, r *http.Request) { - var req AIChatRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - SendError(w, http.StatusBadRequest, "Invalid AI request payload") - return - } - - msg, err := h.aiService.GenerateInterviewFeedback(req.ProblemID, req.Prompt, req.CodeSnippet) - if err != nil { - SendError(w, http.StatusServiceUnavailable, err.Error()) - return - } - SendSuccess(w, http.StatusOK, msg) -} diff --git a/interviewhub-backend-go/internal/handlers/health_handlers.go b/interviewhub-backend-go/internal/handlers/health_handlers.go new file mode 100644 index 00000000..8d1ec3ad --- /dev/null +++ b/interviewhub-backend-go/internal/handlers/health_handlers.go @@ -0,0 +1,52 @@ +package handlers + +import ( + "context" + "net/http" + "time" +) + +type migrationReadinessChecker interface { + ValidateMigrations(context.Context) error +} + +func (h *Handlers) HealthCheck(w http.ResponseWriter, _ *http.Request) { + SendSuccess(w, http.StatusOK, map[string]any{ + "status": "healthy", + "service": "JudgeMyCode API", + "version": "1.0.0", + "queue": h.executionService.Metrics(), + }) +} + +func (h *Handlers) Readiness(w http.ResponseWriter, r *http.Request) { + if h.readiness != nil { + readinessContext, cancel := context.WithTimeout(r.Context(), 2*time.Second) + err := h.readiness.Ping(readinessContext) + if err == nil { + if schemaChecker, ok := h.readiness.(migrationReadinessChecker); ok { + err = schemaChecker.ValidateMigrations(readinessContext) + } + } + cancel() + if err != nil { + SendError(w, http.StatusServiceUnavailable, "database is not ready") + return + } + } + // A catalog/auth-only deployment may deliberately disable local execution. + // That is a valid configuration, not a failed API dependency. + if h.cfg.ExecutionEnabled && h.cfg.ExecutionRunner != "disabled" && !h.executionService.Ready() { + SendError(w, http.StatusServiceUnavailable, "execution queue is unavailable") + return + } + SendSuccess(w, http.StatusOK, map[string]any{ + "ready": true, + "service": "JudgeMyCode API", + "executionEnabled": h.cfg.ExecutionEnabled && h.cfg.ExecutionRunner != "disabled", + }) +} + +func (h *Handlers) Metrics(w http.ResponseWriter, _ *http.Request) { + SendSuccess(w, http.StatusOK, h.executionService.Metrics()) +} diff --git a/interviewhub-backend-go/internal/handlers/health_handlers_test.go b/interviewhub-backend-go/internal/handlers/health_handlers_test.go new file mode 100644 index 00000000..3171c7b0 --- /dev/null +++ b/interviewhub-backend-go/internal/handlers/health_handlers_test.go @@ -0,0 +1,68 @@ +package handlers + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +type readinessProbe struct { + pingErr error + migrationErr error + pingCalled bool + migrationCalled bool + pingHadDeadline bool + migrationHadDeadline bool +} + +func (p *readinessProbe) Ping(ctx context.Context) error { + p.pingCalled = true + _, p.pingHadDeadline = ctx.Deadline() + return p.pingErr +} + +func (p *readinessProbe) ValidateMigrations(ctx context.Context) error { + p.migrationCalled = true + deadline, ok := ctx.Deadline() + p.migrationHadDeadline = ok && time.Until(deadline) <= 2*time.Second + return p.migrationErr +} + +func TestReadinessRejectsMigrationValidationFailure(t *testing.T) { + probe := &readinessProbe{migrationErr: errors.New("required migration is missing")} + h := &Handlers{readiness: probe} + recorder := httptest.NewRecorder() + + h.Readiness(recorder, httptest.NewRequest(http.MethodGet, "/api/health/ready", nil)) + + if recorder.Code != http.StatusServiceUnavailable { + t.Fatalf("status = %d, want %d", recorder.Code, http.StatusServiceUnavailable) + } + if !probe.pingCalled || !probe.migrationCalled { + t.Fatalf("checks called: ping=%t migrations=%t, want both", probe.pingCalled, probe.migrationCalled) + } + if !probe.pingHadDeadline || !probe.migrationHadDeadline { + t.Fatalf("bounded context: ping=%t migrations=%t, want both true", probe.pingHadDeadline, probe.migrationHadDeadline) + } +} + +func TestReadinessStopsAfterDatabasePingFailure(t *testing.T) { + probe := &readinessProbe{pingErr: errors.New("database unavailable")} + h := &Handlers{readiness: probe} + recorder := httptest.NewRecorder() + + h.Readiness(recorder, httptest.NewRequest(http.MethodGet, "/api/health/ready", nil)) + + if recorder.Code != http.StatusServiceUnavailable { + t.Fatalf("status = %d, want %d", recorder.Code, http.StatusServiceUnavailable) + } + if !probe.pingCalled { + t.Fatal("Ping() was not called") + } + if probe.migrationCalled { + t.Fatal("ValidateMigrations() was called after Ping() failed") + } +} diff --git a/interviewhub-backend-go/internal/handlers/middleware.go b/interviewhub-backend-go/internal/handlers/middleware.go index 71f88054..d484153f 100644 --- a/interviewhub-backend-go/internal/handlers/middleware.go +++ b/interviewhub-backend-go/internal/handlers/middleware.go @@ -30,10 +30,13 @@ func RequestLoggingMiddleware() func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { start := time.Now() - wrapped := &statusRecorder{ResponseWriter: w, statusCode: http.StatusOK} + wrapped := &statusRecorder{ResponseWriter: w} next.ServeHTTP(wrapped, r) + if wrapped.statusCode == 0 { + wrapped.statusCode = http.StatusOK + } requestID := middleware.GetReqID(r.Context()) - log.Printf(`{"level":"info","msg":"request_completed","request_id":"%s","method":"%s","path":"%s","status":%d,"duration_ms":%d}`, + log.Printf(`{"level":"info","msg":"request_completed","request_id":%q,"method":%q,"path":%q,"status":%d,"duration_ms":%d}`, requestID, r.Method, r.URL.Path, wrapped.statusCode, time.Since(start).Milliseconds()) }) } @@ -45,10 +48,20 @@ type statusRecorder struct { } func (r *statusRecorder) WriteHeader(statusCode int) { + if r.statusCode != 0 { + return + } r.statusCode = statusCode r.ResponseWriter.WriteHeader(statusCode) } +func (r *statusRecorder) Write(payload []byte) (int, error) { + if r.statusCode == 0 { + r.WriteHeader(http.StatusOK) + } + return r.ResponseWriter.Write(payload) +} + // RequireAuth validates the Bearer JWT (signature, iss, aud, exp). func (m *Middleware) RequireAuth(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -58,7 +71,11 @@ func (m *Middleware) RequireAuth(next http.Handler) http.Handler { return } - tokenString := strings.TrimPrefix(authHeader, "Bearer ") + tokenString := strings.TrimSpace(strings.TrimPrefix(authHeader, "Bearer ")) + if tokenString == "" { + SendError(w, http.StatusUnauthorized, "authentication required") + return + } claims, err := m.auth.ParseAccessToken(tokenString) if err != nil { SendError(w, http.StatusUnauthorized, "Session expired or invalid token. Please sign in again.") @@ -75,7 +92,7 @@ func (m *Middleware) OptionalAuth(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { authHeader := r.Header.Get("Authorization") if authHeader != "" && strings.HasPrefix(authHeader, "Bearer ") { - tokenString := strings.TrimPrefix(authHeader, "Bearer ") + tokenString := strings.TrimSpace(strings.TrimPrefix(authHeader, "Bearer ")) if claims, err := m.auth.ParseAccessToken(tokenString); err == nil { ctx := context.WithValue(r.Context(), UserContextKey, claims) next.ServeHTTP(w, r.WithContext(ctx)) diff --git a/interviewhub-backend-go/internal/handlers/oauth_handlers.go b/interviewhub-backend-go/internal/handlers/oauth_handlers.go index 5f8043cb..bbf4f722 100644 --- a/interviewhub-backend-go/internal/handlers/oauth_handlers.go +++ b/interviewhub-backend-go/internal/handlers/oauth_handlers.go @@ -1,33 +1,52 @@ package handlers import ( - "encoding/json" + "crypto/subtle" "fmt" "net/http" "net/url" "strings" + "time" "github.com/go-chi/chi/v5" ) +const ( + oauthStateCookieName = "judgemycode_oauth_state" + maxOAuthCodeBytes = 2048 + maxOAuthStateBytes = 512 +) + +const oauthPublicFailure = "Sign-in failed; please start again" + // InitiateOAuth redirects user to provider's login screen or returns auth URL func (h *Handlers) InitiateOAuth(w http.ResponseWriter, r *http.Request) { - provider := chi.URLParam(r, "provider") - if provider == "" { + provider, ok := normalizedOAuthProvider(chi.URLParam(r, "provider")) + if !ok { SendError(w, http.StatusBadRequest, "OAuth provider is required") return } + if !h.authService.AllowAuthAttempt(authRateBucketKey("oauth-init-ip", clientAddress(r, h.cfg))) { + SendError(w, http.StatusTooManyRequests, "too many sign-in attempts; please try again later") + return + } authURL, err := h.oauthService.GetAuthorizationURL(provider) if err != nil { if strings.Contains(r.Header.Get("Accept"), "application/json") || r.URL.Query().Get("format") == "json" { - SendError(w, http.StatusBadRequest, err.Error()) + SendError(w, http.StatusBadRequest, oauthPublicFailure) return } - targetURL := fmt.Sprintf("%s/?oauth_error=%s", h.cfg.FrontendURL, url.QueryEscape(err.Error())) + targetURL := fmt.Sprintf("%s/?oauth_error=%s", h.cfg.FrontendURL, url.QueryEscape(oauthPublicFailure)) http.Redirect(w, r, targetURL, http.StatusTemporaryRedirect) return } + parsedAuthURL, err := url.Parse(authURL) + if err != nil || parsedAuthURL.Query().Get("state") == "" { + SendError(w, http.StatusInternalServerError, "failed to initialize OAuth session") + return + } + h.writeOAuthStateCookie(w, parsedAuthURL.Query().Get("state")) if strings.Contains(r.Header.Get("Accept"), "application/json") || r.URL.Query().Get("format") == "json" { SendSuccess(w, http.StatusOK, map[string]string{ @@ -42,33 +61,47 @@ func (h *Handlers) InitiateOAuth(w http.ResponseWriter, r *http.Request) { // OAuthCallback handles the redirect back from the OAuth provider func (h *Handlers) OAuthCallback(w http.ResponseWriter, r *http.Request) { - provider := chi.URLParam(r, "provider") + provider, providerOK := normalizedOAuthProvider(chi.URLParam(r, "provider")) + state := r.URL.Query().Get("state") + if !providerOK || len(state) == 0 || len(state) > maxOAuthStateBytes || + !h.authService.AllowAuthAttempt(authRateBucketKey("oauth-callback-ip", clientAddress(r, h.cfg))) { + h.clearOAuthStateCookie(w) + targetURL := fmt.Sprintf("%s/?oauth_error=%s", h.cfg.FrontendURL, url.QueryEscape(oauthPublicFailure)) + http.Redirect(w, r, targetURL, http.StatusTemporaryRedirect) + return + } // 1. Check if provider returned an error (e.g. user cancelled) if errParam := r.URL.Query().Get("error"); errParam != "" { - errDesc := r.URL.Query().Get("error_description") - if errDesc == "" { - errDesc = errParam + if !h.consumeOAuthStateCookie(w, r, state) || !h.oauthService.ValidateState(provider, state) { + targetURL := fmt.Sprintf("%s/?oauth_error=%s", h.cfg.FrontendURL, url.QueryEscape("OAuth session mismatch; please start sign-in again")) + http.Redirect(w, r, targetURL, http.StatusTemporaryRedirect) + return } - targetURL := fmt.Sprintf("%s/?oauth_error=%s", h.cfg.FrontendURL, url.QueryEscape(fmt.Sprintf("%s sign-in failed: %s", provider, errDesc))) + targetURL := fmt.Sprintf("%s/?oauth_error=%s", h.cfg.FrontendURL, url.QueryEscape(oauthPublicFailure)) http.Redirect(w, r, targetURL, http.StatusTemporaryRedirect) return } code := r.URL.Query().Get("code") - state := r.URL.Query().Get("state") - if code == "" || state == "" { + if code == "" || len(code) > maxOAuthCodeBytes { + h.clearOAuthStateCookie(w) targetURL := fmt.Sprintf("%s/?oauth_error=%s", h.cfg.FrontendURL, url.QueryEscape("Missing authorization code or state parameter")) http.Redirect(w, r, targetURL, http.StatusTemporaryRedirect) return } + if !h.consumeOAuthStateCookie(w, r, state) { + targetURL := fmt.Sprintf("%s/?oauth_error=%s", h.cfg.FrontendURL, url.QueryEscape("OAuth session mismatch; please start sign-in again")) + http.Redirect(w, r, targetURL, http.StatusTemporaryRedirect) + return + } // 2. Resolve the OAuth user, then issue a one-time handoff code. // Never put access/refresh tokens in the browser URL (leaks via Referer, logs, history). user, err := h.oauthService.ResolveOAuthUser(r.Context(), provider, code, state) if err != nil { - targetURL := fmt.Sprintf("%s/?oauth_error=%s", h.cfg.FrontendURL, url.QueryEscape(err.Error())) + targetURL := fmt.Sprintf("%s/?oauth_error=%s", h.cfg.FrontendURL, url.QueryEscape(oauthPublicFailure)) http.Redirect(w, r, targetURL, http.StatusTemporaryRedirect) return } @@ -80,7 +113,10 @@ func (h *Handlers) OAuthCallback(w http.ResponseWriter, r *http.Request) { return } - successURL := fmt.Sprintf("%s/?oauth_code=%s", h.cfg.FrontendURL, url.QueryEscape(handoff)) + // Put the short-lived handoff code in the URL fragment. Fragments are not + // sent in HTTP requests or Referer headers, so frontend asset/server logs do + // not receive the credential before the SPA can exchange and erase it. + successURL := fmt.Sprintf("%s/#oauth_code=%s", strings.TrimRight(h.cfg.FrontendURL, "/"), url.QueryEscape(handoff)) http.Redirect(w, r, successURL, http.StatusTemporaryRedirect) } @@ -91,23 +127,76 @@ type OAuthExchangeRequest struct { // OAuthTokenExchange is an optional JSON API endpoint for client-side code exchange func (h *Handlers) OAuthTokenExchange(w http.ResponseWriter, r *http.Request) { - provider := chi.URLParam(r, "provider") + provider, providerOK := normalizedOAuthProvider(chi.URLParam(r, "provider")) var req OAuthExchangeRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + if err := decodeJSON(r, &req); err != nil { SendError(w, http.StatusBadRequest, "Invalid request payload") return } - if req.Code == "" || req.State == "" { + if !providerOK || req.Code == "" || len(req.Code) > maxOAuthCodeBytes || req.State == "" || len(req.State) > maxOAuthStateBytes { SendError(w, http.StatusBadRequest, "Both 'code' and 'state' are required") return } + if !h.authService.AllowAuthAttempt(authRateBucketKey("oauth-token-ip", clientAddress(r, h.cfg))) { + SendError(w, http.StatusTooManyRequests, "too many sign-in attempts; please try again later") + return + } + if !h.consumeOAuthStateCookie(w, r, req.State) { + SendError(w, http.StatusUnauthorized, "OAuth session mismatch; please start sign-in again") + return + } authResp, err := h.oauthService.ExchangeAndAuthenticate(r.Context(), provider, req.Code, req.State) if err != nil { - SendError(w, http.StatusUnauthorized, err.Error()) + SendError(w, http.StatusUnauthorized, oauthPublicFailure) return } SendSuccess(w, http.StatusOK, authResp, "OAuth sign-in successful") } + +func normalizedOAuthProvider(raw string) (string, bool) { + provider := strings.ToLower(strings.TrimSpace(raw)) + switch provider { + case "google", "github", "linkedin": + return provider, true + default: + return "", false + } +} + +func (h *Handlers) writeOAuthStateCookie(w http.ResponseWriter, state string) { + http.SetCookie(w, &http.Cookie{ + Name: oauthStateCookieName, + Value: state, + Path: "/api/auth/oauth/", + HttpOnly: true, + Secure: h.cfg.IsProduction() || h.cfg.AppEnv == "staging", + SameSite: http.SameSiteLaxMode, + MaxAge: 600, + Expires: time.Now().Add(10 * time.Minute), + }) +} + +func (h *Handlers) clearOAuthStateCookie(w http.ResponseWriter) { + http.SetCookie(w, &http.Cookie{ + Name: oauthStateCookieName, + Value: "", + Path: "/api/auth/oauth/", + HttpOnly: true, + Secure: h.cfg.IsProduction() || h.cfg.AppEnv == "staging", + SameSite: http.SameSiteLaxMode, + MaxAge: -1, + Expires: time.Unix(1, 0), + }) +} + +func (h *Handlers) consumeOAuthStateCookie(w http.ResponseWriter, r *http.Request, state string) bool { + cookie, err := r.Cookie(oauthStateCookieName) + h.clearOAuthStateCookie(w) + if err != nil || cookie.Value == "" || state == "" { + return false + } + return subtle.ConstantTimeCompare([]byte(cookie.Value), []byte(state)) == 1 +} diff --git a/interviewhub-backend-go/internal/handlers/problem_error_contracts_test.go b/interviewhub-backend-go/internal/handlers/problem_error_contracts_test.go new file mode 100644 index 00000000..e6b6ab3a --- /dev/null +++ b/interviewhub-backend-go/internal/handlers/problem_error_contracts_test.go @@ -0,0 +1,123 @@ +package handlers + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/go-chi/chi/v5" + "github.com/interviewhub/backend/internal/models" + "github.com/interviewhub/backend/internal/services" + "github.com/interviewhub/backend/internal/store" +) + +type unavailableProblemRepository struct { + store.Repository + err error + saveCalls int +} + +func (r *unavailableProblemRepository) LookupAllProblems() ([]models.Problem, error) { + return nil, r.err +} + +func (r *unavailableProblemRepository) LookupProblemByID(string) (models.Problem, bool, error) { + return models.Problem{}, false, r.err +} + +func (r *unavailableProblemRepository) SaveProblem(problem models.Problem) (models.Problem, error) { + r.saveCalls++ + return r.Repository.SaveProblem(problem) +} + +func TestProblemHandlersReturnGenericServiceUnavailableForReadFailures(t *testing.T) { + secret := "postgres://admin:must-not-leak@db.internal/catalog" + repository := &unavailableProblemRepository{ + Repository: store.NewMemoryStore(), + err: errors.New("scan hidden test from " + secret), + } + handler := &Handlers{problemService: services.NewProblemService(repository)} + + tests := []struct { + name string + method string + path string + body string + invoke func(http.ResponseWriter, *http.Request) + }{ + {name: "list", method: http.MethodGet, path: "/api/problems", invoke: handler.ListProblems}, + {name: "detail", method: http.MethodGet, path: "/api/problems/parking-lot", invoke: handler.GetProblem}, + {name: "admin update", method: http.MethodPut, path: "/api/admin/problems/parking-lot", body: `{"title":"Changed"}`, invoke: handler.UpdateProblem}, + } + + for _, testCase := range tests { + t.Run(testCase.name, func(t *testing.T) { + request := httptest.NewRequest(testCase.method, testCase.path, strings.NewReader(testCase.body)) + if testCase.name != "list" { + routeContext := chi.NewRouteContext() + routeContext.URLParams.Add("id", "parking-lot") + request = request.WithContext(context.WithValue(request.Context(), chi.RouteCtxKey, routeContext)) + } + response := httptest.NewRecorder() + + testCase.invoke(response, request) + + if response.Code != http.StatusServiceUnavailable { + t.Fatalf("status = %d body=%s, want 503", response.Code, response.Body.String()) + } + body := response.Body.String() + if !strings.Contains(body, "problem catalog operation failed") { + t.Fatalf("response body = %s, want generic catalog failure", body) + } + if strings.Contains(body, secret) || strings.Contains(body, "must-not-leak") || strings.Contains(body, "hidden test") { + t.Fatalf("response leaked storage details: %s", body) + } + }) + } + + if repository.saveCalls != 0 { + t.Fatalf("SaveProblem called %d times after failed admin read, want 0", repository.saveCalls) + } +} + +func TestProblemUpdateHandlerReturnsConflictForStaleRevision(t *testing.T) { + repository := store.NewMemoryStore() + service := services.NewProblemService(repository) + stale, err := service.GetProblemByID("parking-lot") + if err != nil { + t.Fatal(err) + } + winner := stale + winner.Title = "Concurrent winner" + if _, err := service.UpdateProblem("parking-lot", winner); err != nil { + t.Fatalf("advance problem revision: %v", err) + } + stale.Title = "Stale loser" + payload, err := json.Marshal(stale) + if err != nil { + t.Fatal(err) + } + + request := httptest.NewRequest(http.MethodPut, "/api/admin/problems/parking-lot", bytes.NewReader(payload)) + routeContext := chi.NewRouteContext() + routeContext.URLParams.Add("id", "parking-lot") + request = request.WithContext(context.WithValue(request.Context(), chi.RouteCtxKey, routeContext)) + response := httptest.NewRecorder() + (&Handlers{problemService: service}).UpdateProblem(response, request) + + if response.Code != http.StatusConflict { + t.Fatalf("status = %d body=%s, want 409", response.Code, response.Body.String()) + } + if !strings.Contains(response.Body.String(), "reload and retry") { + t.Fatalf("conflict response is not actionable: %s", response.Body.String()) + } + stored, found := repository.GetProblemByID("parking-lot") + if !found || stored.Title != "Concurrent winner" { + t.Fatalf("stale HTTP update changed winner: found=%v problem=%+v", found, stored) + } +} diff --git a/interviewhub-backend-go/internal/handlers/problem_handlers.go b/interviewhub-backend-go/internal/handlers/problem_handlers.go new file mode 100644 index 00000000..7abd5ad7 --- /dev/null +++ b/interviewhub-backend-go/internal/handlers/problem_handlers.go @@ -0,0 +1,103 @@ +package handlers + +import ( + "errors" + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/interviewhub/backend/internal/models" + "github.com/interviewhub/backend/internal/services" +) + +func (h *Handlers) ListProblems(w http.ResponseWriter, r *http.Request) { + query := r.URL.Query() + filter := services.ProblemFilter{ + Difficulty: query.Get("difficulty"), + Company: query.Get("company"), + Search: query.Get("search"), + } + problems, err := h.problemService.GetProblems(filter) + if err != nil { + sendProblemError(w, err, http.StatusInternalServerError) + return + } + SendSuccess(w, http.StatusOK, problems) +} + +func (h *Handlers) GetProblem(w http.ResponseWriter, r *http.Request) { + problem, err := h.problemService.GetProblemByID(chi.URLParam(r, "id")) + if err != nil { + sendProblemError(w, err, http.StatusNotFound) + return + } + SendSuccess(w, http.StatusOK, problem) +} + +func (h *Handlers) CreateProblem(w http.ResponseWriter, r *http.Request) { + var problem models.Problem + if err := decodeJSON(r, &problem); err != nil { + SendError(w, http.StatusBadRequest, "invalid problem payload: "+err.Error()) + return + } + created, err := h.problemService.CreateProblem(problem) + if err != nil { + sendProblemError(w, err, http.StatusBadRequest) + return + } + SendSuccess(w, http.StatusCreated, created, "problem created") +} + +func (h *Handlers) UpdateProblem(w http.ResponseWriter, r *http.Request) { + var problem models.Problem + if err := decodeJSON(r, &problem); err != nil { + SendError(w, http.StatusBadRequest, "invalid problem payload: "+err.Error()) + return + } + updated, err := h.problemService.UpdateProblem(chi.URLParam(r, "id"), problem) + if err != nil { + sendProblemError(w, err, http.StatusBadRequest) + return + } + SendSuccess(w, http.StatusOK, updated, "problem updated") +} + +func (h *Handlers) DeleteProblem(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + if err := h.problemService.DeleteProblem(id); err != nil { + sendProblemError(w, err, http.StatusNotFound) + return + } + SendSuccess(w, http.StatusOK, map[string]string{"id": id}, "problem deleted") +} + +func (h *Handlers) ResetDefaultProblems(w http.ResponseWriter, _ *http.Request) { + problems, err := h.problemService.ResetToDefaults() + if err != nil { + sendProblemError(w, err, http.StatusInternalServerError) + return + } + SendSuccess(w, http.StatusOK, problems, "problem catalog reset") +} + +func sendProblemError(w http.ResponseWriter, err error, fallback int) { + message := err.Error() + if errors.Is(err, services.ErrProblemPersistence) { + message = "problem catalog operation failed" + } + SendError(w, problemErrorStatus(err, fallback), message) +} + +func problemErrorStatus(err error, fallback int) int { + switch { + case errors.Is(err, services.ErrInvalidProblem): + return http.StatusBadRequest + case errors.Is(err, services.ErrProblemConflict): + return http.StatusConflict + case errors.Is(err, services.ErrProblemNotFound): + return http.StatusNotFound + case errors.Is(err, services.ErrProblemPersistence): + return http.StatusServiceUnavailable + default: + return fallback + } +} diff --git a/interviewhub-backend-go/internal/handlers/refresh_security_test.go b/interviewhub-backend-go/internal/handlers/refresh_security_test.go new file mode 100644 index 00000000..8876d234 --- /dev/null +++ b/interviewhub-backend-go/internal/handlers/refresh_security_test.go @@ -0,0 +1,124 @@ +package handlers + +import ( + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/interviewhub/backend/internal/services" + "github.com/interviewhub/backend/internal/store" +) + +func TestRefreshEndpointHasIPAndCredentialAdmissionLimits(t *testing.T) { + tests := []struct { + name string + remoteAddr func(int) string + token func(int) string + limit int + }{ + { + name: "one source with changing credentials", + remoteAddr: func(int) string { return "192.0.2.10:4567" }, + token: func(attempt int) string { return fmt.Sprintf("invalid-token-%d", attempt) }, + limit: 120, + }, + { + name: "distributed reuse of one credential", + remoteAddr: func(attempt int) string { return fmt.Sprintf("192.0.2.%d:4567", attempt+20) }, + token: func(int) string { return "replayed-invalid-token" }, + limit: 10, + }, + } + + for _, testCase := range tests { + t.Run(testCase.name, func(t *testing.T) { + cfg := authErrorTestConfig() + handler := &Handlers{ + authService: services.NewAuthService(store.NewMemoryStore(), cfg), + cfg: cfg, + } + for attempt := 0; attempt <= testCase.limit; attempt++ { + body := fmt.Sprintf(`{"refreshToken":%q}`, testCase.token(attempt)) + request := httptest.NewRequest(http.MethodPost, "/api/auth/refresh", strings.NewReader(body)) + request.RemoteAddr = testCase.remoteAddr(attempt) + response := httptest.NewRecorder() + + handler.Refresh(response, request) + + want := http.StatusUnauthorized + if attempt == testCase.limit { + want = http.StatusTooManyRequests + } + if response.Code != want { + t.Fatalf("attempt %d status=%d body=%s, want %d", attempt, response.Code, response.Body.String(), want) + } + } + }) + } +} + +func TestLogoutEndpointHasIPAndCredentialAdmissionLimits(t *testing.T) { + tests := []struct { + name string + remoteAddr func(int) string + token func(int) string + limit int + }{ + { + name: "one source with changing credentials", + remoteAddr: func(int) string { return "192.0.2.10:4567" }, + token: func(attempt int) string { return fmt.Sprintf("invalid-token-%d", attempt) }, + limit: 120, + }, + { + name: "distributed reuse of one credential", + remoteAddr: func(attempt int) string { return fmt.Sprintf("192.0.2.%d:4567", attempt+20) }, + token: func(int) string { return "replayed-invalid-token" }, + limit: 10, + }, + } + + for _, testCase := range tests { + t.Run(testCase.name, func(t *testing.T) { + cfg := authErrorTestConfig() + handler := &Handlers{ + authService: services.NewAuthService(store.NewMemoryStore(), cfg), + cfg: cfg, + } + for attempt := 0; attempt <= testCase.limit; attempt++ { + body := fmt.Sprintf(`{"refreshToken":%q}`, testCase.token(attempt)) + request := httptest.NewRequest(http.MethodPost, "/api/auth/logout", strings.NewReader(body)) + request.RemoteAddr = testCase.remoteAddr(attempt) + response := httptest.NewRecorder() + + handler.Logout(response, request) + + want := http.StatusNoContent + if attempt == testCase.limit { + want = http.StatusTooManyRequests + } + if response.Code != want { + t.Fatalf("attempt %d status=%d body=%s, want %d", attempt, response.Code, response.Body.String(), want) + } + } + }) + } +} + +func TestLogoutRejectsMissingCredentialBeforeAdmission(t *testing.T) { + cfg := authErrorTestConfig() + handler := &Handlers{ + authService: services.NewAuthService(store.NewMemoryStore(), cfg), + cfg: cfg, + } + request := httptest.NewRequest(http.MethodPost, "/api/auth/logout", strings.NewReader(`{}`)) + response := httptest.NewRecorder() + + handler.Logout(response, request) + + if response.Code != http.StatusBadRequest { + t.Fatalf("status=%d body=%s, want %d", response.Code, response.Body.String(), http.StatusBadRequest) + } +} diff --git a/interviewhub-backend-go/internal/handlers/request.go b/interviewhub-backend-go/internal/handlers/request.go new file mode 100644 index 00000000..2e11abe0 --- /dev/null +++ b/interviewhub-backend-go/internal/handlers/request.go @@ -0,0 +1,76 @@ +package handlers + +import ( + "encoding/json" + "errors" + "io" + "net" + "net/http" + "strings" + + "github.com/interviewhub/backend/internal/config" + "github.com/interviewhub/backend/internal/services" +) + +func decodeJSON(r *http.Request, destination any) error { + decoder := json.NewDecoder(r.Body) + decoder.DisallowUnknownFields() + if err := decoder.Decode(destination); err != nil { + return err + } + if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { + return errors.New("request body must contain exactly one JSON object") + } + return nil +} + +func claimsFromRequest(r *http.Request) (*services.JWTClaims, bool) { + claims, ok := r.Context().Value(UserContextKey).(*services.JWTClaims) + return claims, ok && claims != nil +} + +func clientAddress(r *http.Request, cfg *config.Config) string { + remote := strings.TrimSpace(r.RemoteAddr) + if host, _, err := net.SplitHostPort(remote); err == nil { + remote = host + } + peer := net.ParseIP(remote) + if peer == nil || cfg == nil || !trustedProxy(peer, cfg.TrustedProxyCIDRs) { + return remote + } + + forwarded := strings.TrimSpace(r.Header.Get("X-Forwarded-For")) + if forwarded == "" || len(forwarded) > 4096 { + return remote + } + parts := strings.Split(forwarded, ",") + if len(parts) > 32 { + return remote + } + chain := make([]net.IP, 0, len(parts)) + for _, raw := range parts { + ip := net.ParseIP(strings.TrimSpace(raw)) + if ip == nil { + // A malformed trusted-proxy header is ignored in full. Falling back to + // the connected peer is safer than accepting a partially spoofed chain. + return remote + } + chain = append(chain, ip) + } + + client := peer + for index := len(chain) - 1; index >= 0 && trustedProxy(client, cfg.TrustedProxyCIDRs); index-- { + client = chain[index] + } + return client.String() +} + +func trustedProxy(ip net.IP, cidrs []string) bool { + for _, rawCIDR := range cidrs { + _, network, err := net.ParseCIDR(rawCIDR) + if err == nil && network.Contains(ip) { + return true + } + } + return false +} diff --git a/interviewhub-backend-go/internal/handlers/request_test.go b/interviewhub-backend-go/internal/handlers/request_test.go new file mode 100644 index 00000000..68161f84 --- /dev/null +++ b/interviewhub-backend-go/internal/handlers/request_test.go @@ -0,0 +1,37 @@ +package handlers + +import ( + "net/http/httptest" + "testing" + + "github.com/interviewhub/backend/internal/config" +) + +func TestClientAddressTrustsForwardingHeadersOnlyFromConfiguredProxies(t *testing.T) { + t.Run("untrusted peer cannot spoof", func(t *testing.T) { + request := httptest.NewRequest("GET", "/", nil) + request.RemoteAddr = "198.51.100.20:443" + request.Header.Set("X-Forwarded-For", "203.0.113.9") + if got := clientAddress(request, &config.Config{TrustedProxyCIDRs: []string{"10.0.0.0/8"}}); got != "198.51.100.20" { + t.Fatalf("clientAddress()=%q, want connected peer", got) + } + }) + + t.Run("rightmost untrusted hop is client", func(t *testing.T) { + request := httptest.NewRequest("GET", "/", nil) + request.RemoteAddr = "10.0.0.3:443" + request.Header.Set("X-Forwarded-For", "203.0.113.9, 10.0.0.2") + if got := clientAddress(request, &config.Config{TrustedProxyCIDRs: []string{"10.0.0.0/8"}}); got != "203.0.113.9" { + t.Fatalf("clientAddress()=%q, want forwarded client", got) + } + }) + + t.Run("untrusted intermediate hop stops chain", func(t *testing.T) { + request := httptest.NewRequest("GET", "/", nil) + request.RemoteAddr = "10.0.0.3:443" + request.Header.Set("X-Forwarded-For", "192.0.2.44, 198.51.100.7") + if got := clientAddress(request, &config.Config{TrustedProxyCIDRs: []string{"10.0.0.0/8"}}); got != "198.51.100.7" { + t.Fatalf("clientAddress()=%q, want nearest untrusted hop", got) + } + }) +} diff --git a/interviewhub-backend-go/internal/handlers/security.go b/interviewhub-backend-go/internal/handlers/security.go index 6461a870..fe6fcee2 100644 --- a/interviewhub-backend-go/internal/handlers/security.go +++ b/interviewhub-backend-go/internal/handlers/security.go @@ -2,13 +2,26 @@ package handlers import "net/http" +// SecurityHeaders applies conservative browser-facing defaults. The API does +// not render HTML, so it can disable framing and content sniffing globally. +func SecurityHeaders(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("X-Frame-Options", "DENY") + w.Header().Set("Referrer-Policy", "no-referrer") + w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()") + w.Header().Set("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'") + next.ServeHTTP(w, r) + }) +} + // LimitRequestBody protects JSON decoding handlers from unexpectedly large // source-code or metadata payloads. Source limits are further enforced by the // future judge worker. func LimitRequestBody(maxBytes int64) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Body != nil { + if r.Body != nil && maxBytes > 0 { r.Body = http.MaxBytesReader(w, r.Body, maxBytes) } next.ServeHTTP(w, r) diff --git a/interviewhub-backend-go/internal/handlers/submission_handlers_test.go b/interviewhub-backend-go/internal/handlers/submission_handlers_test.go new file mode 100644 index 00000000..95aa1908 --- /dev/null +++ b/interviewhub-backend-go/internal/handlers/submission_handlers_test.go @@ -0,0 +1,116 @@ +package handlers + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/go-chi/chi/v5" + "github.com/interviewhub/backend/internal/models" + "github.com/interviewhub/backend/internal/services" + "github.com/interviewhub/backend/internal/store" +) + +func submissionRequest(method, target, userID string, role models.UserRole) *http.Request { + request := httptest.NewRequest(method, target, nil) + claims := &services.JWTClaims{ID: userID, Role: role} + return request.WithContext(context.WithValue(request.Context(), UserContextKey, claims)) +} + +func TestListSubmissionsReturnsBoundedSummaryPage(t *testing.T) { + repository := store.NewMemoryStore() + now := time.Date(2026, time.August, 7, 12, 0, 0, 0, time.UTC) + for _, submission := range []models.Submission{ + {ID: "sub-new", UserID: "usr_cand_01", ProblemID: "parking-lot", Status: models.SubmissionStatusAccepted, Files: []models.FileNode{{ID: "private-source"}}, ConsoleOutput: []string{"private-console"}, CreatedAt: now}, + {ID: "sub-old", UserID: "usr_cand_01", ProblemID: "parking-lot", Status: models.SubmissionStatusWrongAnswer, CreatedAt: now.Add(-time.Minute)}, + {ID: "sub-admin", UserID: "usr_admin_01", ProblemID: "parking-lot", Status: models.SubmissionStatusAccepted, CreatedAt: now.Add(-2 * time.Minute)}, + } { + if _, err := repository.SaveSubmission(submission); err != nil { + t.Fatalf("save submission: %v", err) + } + } + handler := &Handlers{submissionService: services.NewSubmissionService(repository)} + + response := httptest.NewRecorder() + handler.ListSubmissions(response, submissionRequest(http.MethodGet, "/api/submissions?problemId=parking-lot&limit=1", "usr_cand_01", models.RoleCandidate)) + if response.Code != http.StatusOK { + t.Fatalf("status=%d body=%s, want 200", response.Code, response.Body.String()) + } + body := response.Body.String() + for _, expected := range []string{`"items"`, `"sub-new"`, `"nextCursor"`} { + if !strings.Contains(body, expected) { + t.Fatalf("response=%s, missing %s", body, expected) + } + } + for _, forbidden := range []string{`"files"`, `"testResults"`, `"consoleOutput"`, "private-source", "private-console", "sub-admin"} { + if strings.Contains(body, forbidden) { + t.Fatalf("candidate summary response leaked %q: %s", forbidden, body) + } + } +} + +func TestListSubmissionsRejectsUnboundedOrMalformedPagination(t *testing.T) { + handler := &Handlers{submissionService: services.NewSubmissionService(store.NewMemoryStore())} + for _, target := range []string{ + "/api/submissions?limit=101", + "/api/submissions?limit=not-a-number", + "/api/submissions?cursor=not-a-valid-cursor", + } { + response := httptest.NewRecorder() + handler.ListSubmissions(response, submissionRequest(http.MethodGet, target, "usr_cand_01", models.RoleCandidate)) + if response.Code != http.StatusBadRequest { + t.Errorf("target=%s status=%d body=%s, want 400", target, response.Code, response.Body.String()) + } + } +} + +func TestGetSubmissionHidesForeignOwnerAndRedactsDetail(t *testing.T) { + repository := store.NewMemoryStore() + submission := models.Submission{ + ID: "sub-admin-owned", + UserID: "usr_admin_01", + ProblemID: "parking-lot", + EvaluationRevision: "sha256:snapshot", + Status: models.SubmissionStatusWrongAnswer, + TestResults: []models.TestResult{{ + TestCaseID: "historically-hidden", + Name: "Secret case", + Expected: "secret expected", + Actual: "secret actual", + }}, + CreatedAt: time.Now().UTC(), + } + if _, err := repository.SaveSubmission(submission); err != nil { + t.Fatalf("save submission: %v", err) + } + handler := &Handlers{submissionService: services.NewSubmissionService(repository)} + + request := submissionRequest(http.MethodGet, "/api/submissions/sub-admin-owned", "usr_cand_01", models.RoleCandidate) + routeContext := chi.NewRouteContext() + routeContext.URLParams.Add("id", submission.ID) + request = request.WithContext(context.WithValue(request.Context(), chi.RouteCtxKey, routeContext)) + response := httptest.NewRecorder() + handler.GetSubmission(response, request) + if response.Code != http.StatusNotFound { + t.Fatalf("foreign detail status=%d body=%s, want 404", response.Code, response.Body.String()) + } + + request = submissionRequest(http.MethodGet, "/api/submissions/sub-admin-owned", "usr_admin_01", models.RoleAdmin) + routeContext = chi.NewRouteContext() + routeContext.URLParams.Add("id", submission.ID) + request = request.WithContext(context.WithValue(request.Context(), chi.RouteCtxKey, routeContext)) + response = httptest.NewRecorder() + handler.GetSubmission(response, request) + if response.Code != http.StatusOK { + t.Fatalf("owned detail status=%d body=%s, want 200", response.Code, response.Body.String()) + } + body := response.Body.String() + for _, secret := range []string{"historically-hidden", "Secret case", "secret expected", "secret actual"} { + if strings.Contains(body, secret) { + t.Fatalf("owned detail leaked protected metadata %q: %s", secret, body) + } + } +} diff --git a/interviewhub-backend-go/internal/handlers/workspace_handlers.go b/interviewhub-backend-go/internal/handlers/workspace_handlers.go new file mode 100644 index 00000000..92c6eb1e --- /dev/null +++ b/interviewhub-backend-go/internal/handlers/workspace_handlers.go @@ -0,0 +1,80 @@ +package handlers + +import ( + "errors" + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/interviewhub/backend/internal/models" + "github.com/interviewhub/backend/internal/services" +) + +func (h *Handlers) GetWorkspace(w http.ResponseWriter, r *http.Request) { + claims, ok := claimsFromRequest(r) + if !ok { + SendError(w, http.StatusUnauthorized, "authentication required") + return + } + workspace, err := h.workspaceService.Get( + claims.ID, + chi.URLParam(r, "id"), + services.NormalizeLanguage(r.URL.Query().Get("language")), + ) + if err != nil { + sendWorkspaceServiceError(w, err) + return + } + SendSuccess(w, http.StatusOK, workspace) +} + +func (h *Handlers) SaveWorkspace(w http.ResponseWriter, r *http.Request) { + claims, ok := claimsFromRequest(r) + if !ok { + SendError(w, http.StatusUnauthorized, "authentication required") + return + } + var workspace models.Workspace + if err := decodeJSON(r, &workspace); err != nil { + SendError(w, http.StatusBadRequest, "invalid workspace payload") + return + } + saved, err := h.workspaceService.Save(claims.ID, chi.URLParam(r, "id"), workspace) + if err != nil { + sendWorkspaceServiceError(w, err) + return + } + SendSuccess(w, http.StatusOK, saved, "workspace saved") +} + +func (h *Handlers) DeleteWorkspace(w http.ResponseWriter, r *http.Request) { + claims, ok := claimsFromRequest(r) + if !ok { + SendError(w, http.StatusUnauthorized, "authentication required") + return + } + err := h.workspaceService.Delete( + claims.ID, + chi.URLParam(r, "id"), + services.NormalizeLanguage(r.URL.Query().Get("language")), + ) + if err != nil { + sendWorkspaceServiceError(w, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func sendWorkspaceServiceError(w http.ResponseWriter, err error) { + switch { + case errors.Is(err, services.ErrInvalidInput): + SendError(w, http.StatusBadRequest, services.PublicErrorMessage(err, "invalid workspace request")) + case errors.Is(err, services.ErrNotFound): + SendError(w, http.StatusNotFound, services.PublicErrorMessage(err, "workspace resource not found")) + case errors.Is(err, services.ErrConflict): + SendError(w, http.StatusConflict, services.PublicErrorMessage(err, "workspace conflict")) + case errors.Is(err, services.ErrDependencyUnavailable): + SendError(w, http.StatusServiceUnavailable, "workspace service temporarily unavailable") + default: + SendError(w, http.StatusInternalServerError, "unable to complete workspace request") + } +} diff --git a/interviewhub-backend-go/internal/httpapi/router.go b/interviewhub-backend-go/internal/httpapi/router.go new file mode 100644 index 00000000..306483ad --- /dev/null +++ b/interviewhub-backend-go/internal/httpapi/router.go @@ -0,0 +1,99 @@ +package httpapi + +import ( + "net/http" + "time" + + "github.com/go-chi/chi/v5" + chimiddleware "github.com/go-chi/chi/v5/middleware" + "github.com/go-chi/cors" + "github.com/interviewhub/backend/internal/config" + "github.com/interviewhub/backend/internal/handlers" +) + +// NewRouter is the single source of truth for production and test routing. +// Keeping authorization at this boundary makes the access policy reviewable. +func NewRouter(cfg *config.Config, apiHandlers *handlers.Handlers, auth *handlers.Middleware) http.Handler { + router := chi.NewRouter() + router.Use(chimiddleware.RequestID) + router.Use(chimiddleware.Recoverer) + router.Use(chimiddleware.Timeout(30 * time.Second)) + router.Use(handlers.SecurityHeaders) + router.Use(handlers.RequestLoggingMiddleware()) + router.Use(handlers.LimitRequestBody(cfg.MaxRequestBytes)) + router.Use(cors.Handler(cors.Options{ + AllowedOrigins: cfg.AllowedOrigins, + AllowedMethods: []string{http.MethodGet, http.MethodPost, http.MethodPut, http.MethodDelete, http.MethodOptions}, + AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"}, + ExposedHeaders: []string{"Link", "X-Request-ID"}, + AllowCredentials: true, + MaxAge: 300, + })) + + router.NotFound(func(w http.ResponseWriter, _ *http.Request) { + handlers.SendError(w, http.StatusNotFound, "route not found") + }) + router.MethodNotAllowed(func(w http.ResponseWriter, _ *http.Request) { + handlers.SendError(w, http.StatusMethodNotAllowed, "method not allowed") + }) + + router.Route("/api", func(api chi.Router) { + api.Get("/health", apiHandlers.HealthCheck) + api.Get("/health/ready", apiHandlers.Readiness) + api.With(auth.RequireAuth, auth.RequireAdmin).Get("/metrics", apiHandlers.Metrics) + + api.Route("/auth", func(routes chi.Router) { + routes.Post("/register", apiHandlers.Register) + routes.Post("/login", apiHandlers.Login) + routes.Post("/refresh", apiHandlers.Refresh) + routes.Post("/logout", apiHandlers.Logout) + routes.Post("/exchange", apiHandlers.ExchangeAuthCode) + routes.With(auth.RequireAuth).Get("/me", apiHandlers.GetMe) + routes.Get("/oauth/{provider}", apiHandlers.InitiateOAuth) + routes.Get("/oauth/{provider}/callback", apiHandlers.OAuthCallback) + routes.Post("/oauth/{provider}/token", apiHandlers.OAuthTokenExchange) + }) + + api.With(auth.RequireAuth).Get("/users/me/progress", apiHandlers.GetMyProgress) + + api.Route("/problems", func(routes chi.Router) { + routes.Get("/", apiHandlers.ListProblems) + routes.Get("/{id}", apiHandlers.GetProblem) + routes.With(auth.RequireAuth).Get("/{id}/workspace", apiHandlers.GetWorkspace) + routes.With(auth.RequireAuth).Put("/{id}/workspace", apiHandlers.SaveWorkspace) + routes.With(auth.RequireAuth).Delete("/{id}/workspace", apiHandlers.DeleteWorkspace) + }) + + api.Route("/admin/problems", func(routes chi.Router) { + routes.Use(auth.RequireAuth, auth.RequireAdmin) + routes.Post("/", apiHandlers.CreateProblem) + routes.Put("/{id}", apiHandlers.UpdateProblem) + routes.Delete("/{id}", apiHandlers.DeleteProblem) + routes.Post("/reset/defaults", apiHandlers.ResetDefaultProblems) + }) + + api.Route("/execute", func(routes chi.Router) { + routes.Use(auth.RequireAuth) + routes.Post("/run", apiHandlers.RunCode) + routes.Post("/submit", apiHandlers.SubmitCode) + }) + + api.Route("/executions", func(routes chi.Router) { + routes.Use(auth.RequireAuth) + routes.Get("/{id}", apiHandlers.GetExecution) + }) + + api.Route("/submissions", func(routes chi.Router) { + routes.Use(auth.RequireAuth) + routes.Get("/", apiHandlers.ListSubmissions) + routes.Get("/{id}", apiHandlers.GetSubmission) + }) + + api.Route("/ai", func(routes chi.Router) { + routes.Use(auth.RequireAuth) + routes.Post("/chat", apiHandlers.AIChat) + }) + }) + + return router +} diff --git a/interviewhub-backend-go/internal/models/models.go b/interviewhub-backend-go/internal/models/models.go index e269bb2b..5413aa86 100644 --- a/interviewhub-backend-go/internal/models/models.go +++ b/interviewhub-backend-go/internal/models/models.go @@ -95,19 +95,19 @@ const ( // TestCase represents an evaluation unit that hits APIs from the boilerplate. type TestCase struct { - ID string `json:"id"` - Name string `json:"name"` - Description string `json:"description"` - Category TestCategory `json:"category"` - Visibility TestVisibility `json:"visibility,omitempty"` - APIMethod string `json:"apiMethod,omitempty"` // e.g. parkVehicle — must match Problem.APIs - Input string `json:"input"` - ExpectedOutput string `json:"expectedOutput"` - ActualOutput string `json:"actualOutput,omitempty"` - Status string `json:"status"` // "idle", "running", "passed", "failed" - DurationMs int64 `json:"durationMs,omitempty"` - TimeoutMs int `json:"timeoutMs,omitempty"` - ConcurrentWorkers int `json:"concurrentWorkers,omitempty"` // >1 for concurrency tests + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Category TestCategory `json:"category"` + Visibility TestVisibility `json:"visibility,omitempty"` + APIMethod string `json:"apiMethod,omitempty"` // optional metadata exposed to the entrypoint as JUDGEMYCODE_API_METHOD + Input string `json:"input"` + ExpectedOutput string `json:"expectedOutput"` + ActualOutput string `json:"actualOutput,omitempty"` + Status string `json:"status"` // "idle", "running", "passed", "failed" + DurationMs int64 `json:"durationMs,omitempty"` + TimeoutMs int `json:"timeoutMs,omitempty"` + ConcurrentWorkers int `json:"concurrentWorkers,omitempty"` // >1 for concurrency tests } // Tips holds interview patterns and advice @@ -136,10 +136,10 @@ type Problem struct { Tips Tips `json:"tips"` SolutionExplanation string `json:"solutionExplanation"` ClassDiagramMermaid string `json:"classDiagramMermaid"` - StarterFiles map[Language][]FileNode `json:"starterFiles"` // boilerplate file trees - BoilerplateRefs map[Language]string `json:"boilerplateRefs,omitempty"` // optional external storage refs - TestCases []TestCase `json:"testCases"` // public API tests - HiddenTestCases []TestCase `json:"hiddenTestCases,omitempty"` // server-owned + StarterFiles map[Language][]FileNode `json:"starterFiles"` // boilerplate file trees + BoilerplateRefs map[Language]string `json:"boilerplateRefs,omitempty"` // optional external storage refs + TestCases []TestCase `json:"testCases"` // public API tests + HiddenTestCases []TestCase `json:"hiddenTestCases,omitempty"` // server-owned CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` } @@ -157,29 +157,70 @@ type TestResult struct { // Submission represents a candidate's submission record type Submission struct { - ID string `json:"id"` - UserID string `json:"userId"` - UserEmail string `json:"userEmail,omitempty"` - ProblemID string `json:"problemId"` - ProblemTitle string `json:"problemTitle,omitempty"` - Language Language `json:"language"` - Files []FileNode `json:"files"` - Status string `json:"status"` // "ACCEPTED", "WRONG_ANSWER", "COMPILE_ERROR", "RUNTIME_ERROR", "TIME_LIMIT_EXCEEDED" - PassedTests int `json:"passedTests"` - TotalTests int `json:"totalTests"` - Score int `json:"score"` // 0-100 percentage - DurationMs int64 `json:"durationMs"` - TestResults []TestResult `json:"testResults"` - ConsoleOutput []string `json:"consoleOutput"` - CreatedAt time.Time `json:"createdAt"` + ID string `json:"id"` + UserID string `json:"userId"` + UserEmail string `json:"userEmail,omitempty"` + ProblemID string `json:"problemId"` + ProblemTitle string `json:"problemTitle,omitempty"` + EvaluationRevision string `json:"evaluationRevision,omitempty"` + Language Language `json:"language"` + Files []FileNode `json:"files"` + Status string `json:"status"` // "ACCEPTED", "WRONG_ANSWER", "COMPILE_ERROR", "RUNTIME_ERROR", "TIME_LIMIT_EXCEEDED" + PassedTests int `json:"passedTests"` + TotalTests int `json:"totalTests"` + Score int `json:"score"` // 0-100 percentage + DurationMs int64 `json:"durationMs"` + TestResults []TestResult `json:"testResults"` + ConsoleOutput []string `json:"consoleOutput"` + CreatedAt time.Time `json:"createdAt"` } +// SubmissionSummary is the lightweight projection returned by history-list +// endpoints. Source files, per-test output, and console output remain available +// only from the owner-scoped detail endpoint, preventing list views from +// repeatedly transferring or decoding the largest parts of a submission. +type SubmissionSummary struct { + ID string `json:"id"` + UserID string `json:"userId"` + UserEmail string `json:"userEmail,omitempty"` + ProblemID string `json:"problemId"` + ProblemTitle string `json:"problemTitle,omitempty"` + EvaluationRevision string `json:"evaluationRevision,omitempty"` + Language Language `json:"language"` + Status string `json:"status"` + PassedTests int `json:"passedTests"` + TotalTests int `json:"totalTests"` + Score int `json:"score"` + DurationMs int64 `json:"durationMs"` + CreatedAt time.Time `json:"createdAt"` +} + +// SubmissionPage is a bounded, cursor-addressable history window. NextCursor +// is omitted when the current window reached the end of the result set. +type SubmissionPage struct { + Items []SubmissionSummary `json:"items"` + NextCursor string `json:"nextCursor,omitempty"` +} + +const ( + SubmissionStatusAccepted = "ACCEPTED" + SubmissionStatusWrongAnswer = "WRONG_ANSWER" + SubmissionStatusCompileError = "COMPILE_ERROR" + SubmissionStatusRuntimeError = "RUNTIME_ERROR" + SubmissionStatusTimeLimitExceeded = "TIME_LIMIT_EXCEEDED" + SubmissionStatusOutputLimitExceeded = "OUTPUT_LIMIT_EXCEEDED" + SubmissionStatusRuntimeUnavailable = "RUNTIME_UNAVAILABLE" + SubmissionStatusEvaluationUnavailable = "EVALUATION_UNAVAILABLE" +) + // ExecutionRequest payload from client type ExecutionRequest struct { ProblemID string `json:"problemId"` Language Language `json:"language"` Files []FileNode `json:"files"` CustomInput string `json:"customInput,omitempty"` + TestCaseID string `json:"testCaseId,omitempty"` + RunTests bool `json:"runTests,omitempty"` TestCases []TestCase `json:"testCases,omitempty"` } @@ -213,20 +254,24 @@ const ( ) type ExecutionJob struct { - ID string `json:"id"` - Kind ExecutionKind `json:"kind"` - UserID string `json:"userId,omitempty"` - Request ExecutionRequest `json:"request"` - Status ExecutionStatus `json:"status"` - Result *ExecutionResult `json:"result,omitempty"` - Error string `json:"error,omitempty"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` + ID string `json:"id"` + Kind ExecutionKind `json:"kind"` + UserID string `json:"userId,omitempty"` + Request ExecutionRequest `json:"-"` + EvaluationProblem Problem `json:"-"` + EvaluationRevision string `json:"-"` + Status ExecutionStatus `json:"status"` + Result *ExecutionResult `json:"result,omitempty"` + Submission *Submission `json:"submission,omitempty"` + Error string `json:"error,omitempty"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` } type ExecutionEnvelope struct { ExecutionID string `json:"executionId"` Status ExecutionStatus `json:"status"` Result *ExecutionResult `json:"result,omitempty"` + Submission *Submission `json:"submission,omitempty"` Message string `json:"message,omitempty"` } diff --git a/interviewhub-backend-go/internal/models/workspace.go b/interviewhub-backend-go/internal/models/workspace.go index 49f35175..6f9a2d06 100644 --- a/interviewhub-backend-go/internal/models/workspace.go +++ b/interviewhub-backend-go/internal/models/workspace.go @@ -10,6 +10,7 @@ type Workspace struct { Language Language `json:"language"` Files []FileNode `json:"files"` ActiveFileID string `json:"activeFileId,omitempty"` + Revision int64 `json:"revision"` UpdatedAt time.Time `json:"updatedAt"` } @@ -21,8 +22,9 @@ type UserPreferences struct { // UserProgress is calculated from recorded submissions and never accepted from a client. type UserProgress struct { - SolvedCount int `json:"solvedCount"` - SubmissionCount int `json:"submissionCount"` - AcceptedCount int `json:"acceptedCount"` - CurrentStreakDays int `json:"currentStreakDays"` + SolvedCount int `json:"solvedCount"` + SubmissionCount int `json:"submissionCount"` + AcceptedCount int `json:"acceptedCount"` + CurrentStreakDays int `json:"currentStreakDays"` + SolvedProblemIDs []string `json:"solvedProblemIds"` } diff --git a/interviewhub-backend-go/internal/services/ai_service.go b/interviewhub-backend-go/internal/services/ai_service.go index 863a250c..251e3da7 100644 --- a/interviewhub-backend-go/internal/services/ai_service.go +++ b/interviewhub-backend-go/internal/services/ai_service.go @@ -1,12 +1,39 @@ package services import ( + "bytes" + "context" + "encoding/json" "errors" + "fmt" + "io" + "net/http" + "strings" + "sync" "time" + "github.com/interviewhub/backend/internal/config" "github.com/interviewhub/backend/internal/store" ) +const ( + maxAIPromptBytes = 4 << 10 + maxAICodeBytes = 16 << 10 + maxAIResponse = 1 << 20 + maxAIRequestsPerWindow = 10 + maxAIRateIdentities = 20_000 + aiRateWindow = 10 * time.Minute + maxConcurrentAIRequests = 4 +) + +var ( + ErrAINotConfigured = errors.New("AI interviewer is not configured") + ErrAIProblemNotFound = errors.New("problem not found") + ErrAIProviderFailure = errors.New("AI provider is unavailable") + ErrAIRateLimited = errors.New("AI request quota exceeded") + ErrAIBusy = errors.New("AI interviewer is busy") +) + type AIChatMessage struct { Role string `json:"role"` Category string `json:"category"` @@ -15,19 +42,197 @@ type AIChatMessage struct { } type AIService struct { - store store.Repository + store store.Repository + cfg *config.Config + httpClient *http.Client + rateMu sync.Mutex + rateEvents map[string][]time.Time + rateSweep time.Time + concurrency chan struct{} +} + +// NewAIService accepts an optional config to keep test composition lightweight. +// Without an explicitly configured provider, requests fail closed with a clear +// service-unavailable response; no fabricated interview feedback is returned. +func NewAIService(repository store.Repository, configs ...*config.Config) *AIService { + cfg := &config.Config{} + if len(configs) > 0 && configs[0] != nil { + cfg = configs[0] + } + timeout := cfg.AIRequestTimeout + if timeout <= 0 { + timeout = 20 * time.Second + } + if cfg.AIMaxOutputTokens <= 0 { + cfg.AIMaxOutputTokens = 700 + } + return &AIService{ + store: repository, + cfg: cfg, + rateEvents: make(map[string][]time.Time), + concurrency: make(chan struct{}, maxConcurrentAIRequests), + httpClient: &http.Client{ + Timeout: timeout, + }, + } } -func NewAIService(s store.Repository) *AIService { - return &AIService{store: s} +// GenerateInterviewFeedback calls an explicitly configured OpenAI-compatible +// chat-completions endpoint. The prompt includes only public problem fields and +// the caller's own source snippet; hidden cases and reference solutions are not +// sent to the provider. +func (a *AIService) GenerateInterviewFeedback(ctx context.Context, userID, clientIP, problemID, prompt, codeSnippet string) (AIChatMessage, error) { + if strings.TrimSpace(a.cfg.AIAPIURL) == "" || strings.TrimSpace(a.cfg.AIAPIKey) == "" || strings.TrimSpace(a.cfg.AIModel) == "" { + return AIChatMessage{}, ErrAINotConfigured + } + prompt = strings.TrimSpace(prompt) + if prompt == "" { + return AIChatMessage{}, errors.New("prompt is required") + } + if len(prompt) > maxAIPromptBytes { + return AIChatMessage{}, fmt.Errorf("prompt exceeds %d bytes", maxAIPromptBytes) + } + if len(codeSnippet) > maxAICodeBytes { + return AIChatMessage{}, fmt.Errorf("code snippet exceeds %d bytes", maxAICodeBytes) + } + problem, ok, err := lookupProblemByID(a.store, strings.TrimSpace(problemID)) + if err != nil { + return AIChatMessage{}, serviceError(ErrDependencyUnavailable, "problem catalog is unavailable", err) + } + if !ok { + return AIChatMessage{}, ErrAIProblemNotFound + } + if !a.allowAIRequest("user:"+userID) || !a.allowAIRequest("ip:"+clientIP) { + return AIChatMessage{}, ErrAIRateLimited + } + select { + case a.concurrency <- struct{}{}: + defer func() { <-a.concurrency }() + default: + return AIChatMessage{}, ErrAIBusy + } + + systemPrompt := "You are a senior software engineer conducting an LLD interview. Give concise, actionable guidance. Ask guiding questions before revealing a full solution. Evaluate SOLID principles, boundaries, failure modes, concurrency, testing, and trade-offs. Never claim to have executed code." + userPrompt := fmt.Sprintf( + "Problem: %s\nDescription: %s\nFunctional requirements:\n- %s\nNon-functional requirements:\n- %s\n\nCandidate question:\n%s", + problem.Title, + problem.Description, + strings.Join(problem.FunctionalRequirements, "\n- "), + strings.Join(problem.NonFunctionalRequirements, "\n- "), + prompt, + ) + if strings.TrimSpace(codeSnippet) != "" { + userPrompt += "\n\nCandidate code excerpt:\n```\n" + codeSnippet + "\n```" + } + + payload := struct { + Model string `json:"model"` + Messages []struct { + Role string `json:"role"` + Content string `json:"content"` + } `json:"messages"` + Temperature float64 `json:"temperature"` + MaxTokens int `json:"max_tokens"` + }{Model: a.cfg.AIModel, Temperature: 0.2, MaxTokens: a.cfg.AIMaxOutputTokens} + payload.Messages = append(payload.Messages, + struct { + Role string `json:"role"` + Content string `json:"content"` + }{Role: "system", Content: systemPrompt}, + struct { + Role string `json:"role"` + Content string `json:"content"` + }{Role: "user", Content: userPrompt}, + ) + body, err := json.Marshal(payload) + if err != nil { + return AIChatMessage{}, fmt.Errorf("encode AI request: %w", err) + } + + request, err := http.NewRequestWithContext(ctx, http.MethodPost, a.cfg.AIAPIURL, bytes.NewReader(body)) + if err != nil { + return AIChatMessage{}, fmt.Errorf("create AI request: %w", err) + } + request.Header.Set("Authorization", "Bearer "+a.cfg.AIAPIKey) + request.Header.Set("Content-Type", "application/json") + + response, err := a.httpClient.Do(request) + if err != nil { + return AIChatMessage{}, ErrAIProviderFailure + } + defer response.Body.Close() + responseBody, err := io.ReadAll(io.LimitReader(response.Body, maxAIResponse+1)) + if err != nil { + return AIChatMessage{}, fmt.Errorf("%w: failed to read response", ErrAIProviderFailure) + } + if len(responseBody) > maxAIResponse { + return AIChatMessage{}, fmt.Errorf("%w: response exceeded size limit", ErrAIProviderFailure) + } + if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices { + return AIChatMessage{}, fmt.Errorf("%w (status %d)", ErrAIProviderFailure, response.StatusCode) + } + + var decoded struct { + Choices []struct { + Message struct { + Content string `json:"content"` + } `json:"message"` + } `json:"choices"` + } + if err := json.Unmarshal(responseBody, &decoded); err != nil || len(decoded.Choices) == 0 { + return AIChatMessage{}, fmt.Errorf("%w: invalid response", ErrAIProviderFailure) + } + content := strings.TrimSpace(decoded.Choices[0].Message.Content) + if content == "" { + return AIChatMessage{}, fmt.Errorf("%w: empty response", ErrAIProviderFailure) + } + category := "general" + if strings.TrimSpace(codeSnippet) != "" { + category = "review" + } + return AIChatMessage{ + Role: "assistant", + Category: category, + Content: content, + Timestamp: time.Now().UTC(), + }, nil } -// GenerateInterviewFeedback is intentionally not mocked. -// Wire an LLM provider here; until then callers receive a clear error. -func (a *AIService) GenerateInterviewFeedback(problemID, prompt, codeSnippet string) (AIChatMessage, error) { - _ = problemID - _ = prompt - _ = codeSnippet - _ = a.store - return AIChatMessage{}, errors.New("AI interviewer is not configured; no mock responses are served") +func (a *AIService) allowAIRequest(key string) bool { + now := time.Now() + cutoff := now.Add(-aiRateWindow) + a.rateMu.Lock() + defer a.rateMu.Unlock() + if a.rateSweep.IsZero() || now.Sub(a.rateSweep) >= time.Minute { + for bucketKey, events := range a.rateEvents { + active := events[:0] + for _, event := range events { + if event.After(cutoff) { + active = append(active, event) + } + } + if len(active) == 0 { + delete(a.rateEvents, bucketKey) + } else { + a.rateEvents[bucketKey] = active + } + } + a.rateSweep = now + } + if _, exists := a.rateEvents[key]; !exists && len(a.rateEvents) >= maxAIRateIdentities { + return false + } + events := a.rateEvents[key] + active := events[:0] + for _, event := range events { + if event.After(cutoff) { + active = append(active, event) + } + } + if len(active) >= maxAIRequestsPerWindow { + a.rateEvents[key] = active + return false + } + a.rateEvents[key] = append(active, now) + return true } diff --git a/interviewhub-backend-go/internal/services/ai_service_test.go b/interviewhub-backend-go/internal/services/ai_service_test.go new file mode 100644 index 00000000..1bdb1778 --- /dev/null +++ b/interviewhub-backend-go/internal/services/ai_service_test.go @@ -0,0 +1,63 @@ +package services + +import ( + "context" + "encoding/json" + "io" + "net/http" + "strings" + "testing" + "time" + + "github.com/interviewhub/backend/internal/config" + "github.com/interviewhub/backend/internal/models" + "github.com/interviewhub/backend/internal/store" +) + +type aiRoundTripFunc func(*http.Request) (*http.Response, error) + +func (roundTrip aiRoundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) { + return roundTrip(request) +} + +func TestAIProviderRequestIsBoundedAndExcludesServerOwnedMaterial(t *testing.T) { + repository := store.NewMemoryStore() + _, err := repository.SaveProblem(models.Problem{ + ID: "ai-test", Title: "Public title", Description: "Public description", + SolutionExplanation: "solution-secret", + TestCases: []models.TestCase{{ID: "public", Visibility: models.TestPublic, ExpectedOutput: "public-output"}}, + HiddenTestCases: []models.TestCase{{ID: "hidden", Visibility: models.TestHidden, ExpectedOutput: "hidden-secret"}}, + }) + if err != nil { + t.Fatal(err) + } + + var providerPayload []byte + cfg := &config.Config{ + AIAPIURL: "https://provider.invalid/chat", AIAPIKey: "test-key", AIModel: "test-model", + AIRequestTimeout: time.Second, AIMaxOutputTokens: 321, + } + service := NewAIService(repository, cfg) + service.httpClient.Transport = aiRoundTripFunc(func(request *http.Request) (*http.Response, error) { + providerPayload, _ = io.ReadAll(request.Body) + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(`{"choices":[{"message":{"content":"Review response"}}]}`)), + Request: request, + }, nil + }) + message, err := service.GenerateInterviewFeedback(context.Background(), "user-1", "192.0.2.1", "ai-test", "What should I improve?", "class Candidate {}") + if err != nil || message.Content != "Review response" { + t.Fatalf("GenerateInterviewFeedback()=(%+v,%v)", message, err) + } + if strings.Contains(string(providerPayload), "hidden-secret") || strings.Contains(string(providerPayload), "solution-secret") { + t.Fatalf("provider payload leaked server-owned material: %s", providerPayload) + } + var decoded struct { + MaxTokens int `json:"max_tokens"` + } + if err := json.Unmarshal(providerPayload, &decoded); err != nil || decoded.MaxTokens != 321 { + t.Fatalf("provider max_tokens=%d decodeErr=%v payload=%s", decoded.MaxTokens, err, providerPayload) + } +} diff --git a/interviewhub-backend-go/internal/services/auth_service.go b/interviewhub-backend-go/internal/services/auth_service.go index aec55180..fa453d6b 100644 --- a/interviewhub-backend-go/internal/services/auth_service.go +++ b/interviewhub-backend-go/internal/services/auth_service.go @@ -20,9 +20,14 @@ import ( ) const ( - jwtIssuer = "judgemycode" - jwtAudience = "judgemycode-api" - authCodeTTL = 2 * time.Minute + jwtIssuer = "judgemycode" + jwtAudience = "judgemycode-api" + authCodeTTL = 2 * time.Minute + authRateWindow = 15 * time.Minute + authRateMaxAttempts = 10 + authRateMaxIdentities = 10_000 + refreshRateMaxSourceAttempts = 120 + refreshRateMaxCredentialAttempts = 10 ) // JWTClaims holds standard JWT token payloads (RFC 7519 style claims). @@ -53,6 +58,7 @@ type AuthService struct { cfg *config.Config rateMu sync.Mutex rateBucket map[string][]time.Time + rateSweep time.Time } func NewAuthService(s store.Repository, cfg *config.Config) *AuthService { @@ -65,6 +71,10 @@ func NewAuthService(s store.Repository, cfg *config.Config) *AuthService { var emailPattern = regexp.MustCompile(`^[^\s@]+@[^\s@]+\.[^\s@]+$`) +// A default-cost hash keeps the missing-user path close to a real password +// verification without allocating a new hash for every failed login. +var invalidPasswordHash, _ = bcrypt.GenerateFromPassword([]byte("invalid-password-probe"), bcrypt.DefaultCost) + // ValidatePassword enforces a practical industry baseline: length + complexity. func ValidatePassword(password string) error { if len(password) < 8 { @@ -99,13 +109,45 @@ func ValidateEmail(email string) error { // AllowAuthAttempt applies a simple per-key rate limit (login/register/oauth). func (a *AuthService) AllowAuthAttempt(key string) bool { - const maxAttempts = 10 - const window = 15 * time.Minute + return a.allowAuthAttempt(key, authRateMaxAttempts) +} + +// AllowRefreshSourceAttempt gives shared networks enough headroom for routine +// session renewal while still bounding one source's aggregate request rate. +func (a *AuthService) AllowRefreshSourceAttempt(key string) bool { + return a.allowAuthAttempt(key, refreshRateMaxSourceAttempts) +} +// AllowRefreshCredentialAttempt strictly bounds replay of one bearer value, +// including attempts distributed across multiple source addresses. +func (a *AuthService) AllowRefreshCredentialAttempt(key string) bool { + return a.allowAuthAttempt(key, refreshRateMaxCredentialAttempts) +} + +func (a *AuthService) allowAuthAttempt(key string, maxAttempts int) bool { a.rateMu.Lock() defer a.rateMu.Unlock() now := time.Now() - cutoff := now.Add(-window) + cutoff := now.Add(-authRateWindow) + if a.rateSweep.IsZero() || now.Sub(a.rateSweep) >= time.Minute { + for bucketKey, bucketEvents := range a.rateBucket { + active := bucketEvents[:0] + for _, timestamp := range bucketEvents { + if timestamp.After(cutoff) { + active = append(active, timestamp) + } + } + if len(active) == 0 { + delete(a.rateBucket, bucketKey) + } else { + a.rateBucket[bucketKey] = active + } + } + a.rateSweep = now + } + if _, exists := a.rateBucket[key]; !exists && len(a.rateBucket) >= authRateMaxIdentities { + return false + } events := a.rateBucket[key] kept := events[:0] for _, ts := range events { @@ -125,27 +167,31 @@ func (a *AuthService) AllowAuthAttempt(key string) bool { func (a *AuthService) Register(name, email, password string, role models.UserRole) (*AuthResponse, error) { email = strings.TrimSpace(strings.ToLower(email)) if err := ValidateEmail(email); err != nil { - return nil, err + return nil, serviceError(ErrInvalidInput, err.Error(), nil) } if strings.TrimSpace(name) == "" { - return nil, errors.New("name is required") + return nil, serviceError(ErrInvalidInput, "name is required", nil) } if err := ValidatePassword(password); err != nil { - return nil, err + return nil, serviceError(ErrInvalidInput, err.Error(), nil) + } + _, exists, err := lookupUserByEmail(a.store, email) + if err != nil { + return nil, serviceError(ErrDependencyUnavailable, "account storage is unavailable", err) } - if _, exists := a.store.GetUserByEmail(email); exists { - return nil, errors.New("an account with this email already exists") + if exists { + return nil, serviceError(ErrConflict, "an account with this email already exists", nil) } hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { - return nil, err + return nil, serviceError(ErrInternal, "unable to create account", err) } // Public registration must never permit privilege selection. _ = role newUser := models.User{ - ID: "usr_" + uuid.New().String()[:8], + ID: "usr_" + uuid.NewString(), Name: strings.TrimSpace(name), Email: email, PasswordHash: string(hash), @@ -154,9 +200,12 @@ func (a *AuthService) Register(name, email, password string, role models.UserRol CreatedAt: time.Now(), } - created := a.store.CreateUser(newUser) - if created.ID == "" { - return nil, errors.New("failed to create user") + created, err := a.store.CreateUser(newUser) + if err != nil { + if errors.Is(err, store.ErrConflict) { + return nil, serviceError(ErrConflict, "an account with this email already exists", err) + } + return nil, serviceError(ErrDependencyUnavailable, "account storage is unavailable", err) } return a.CreateSession(created) } @@ -164,19 +213,25 @@ func (a *AuthService) Register(name, email, password string, role models.UserRol // Login verifies local credentials and returns a JWT pair. func (a *AuthService) Login(email, password string) (*AuthResponse, error) { email = strings.TrimSpace(strings.ToLower(email)) - user, exists := a.store.GetUserByEmail(email) + user, exists, err := lookupUserByEmail(a.store, email) + if err != nil { + return nil, serviceError(ErrDependencyUnavailable, "account storage is unavailable", err) + } if !exists { - // Constant-ish work to reduce user-enumeration timing gap. - _, _ = bcrypt.GenerateFromPassword([]byte("invalid-password-probe"), bcrypt.MinCost) - return nil, errors.New("invalid email or password") + // Perform the same class of bcrypt work to reduce user-enumeration timing. + _ = bcrypt.CompareHashAndPassword(invalidPasswordHash, []byte(password)) + return nil, serviceError(ErrInvalidCredentials, "invalid email or password", nil) } if user.PasswordHash == "" { - return nil, errors.New("this account uses social sign-in; password login is disabled") + // Keep the public error identical to an unknown account so this endpoint + // cannot be used to enumerate social-login identities. + _ = bcrypt.CompareHashAndPassword(invalidPasswordHash, []byte(password)) + return nil, serviceError(ErrInvalidCredentials, "invalid email or password", nil) } if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil { - return nil, errors.New("invalid email or password") + return nil, serviceError(ErrInvalidCredentials, "invalid email or password", nil) } return a.CreateSession(user) @@ -186,39 +241,73 @@ func (a *AuthService) Login(email, password string) (*AuthResponse, error) { // invalidates the entire token family (theft detection). func (a *AuthService) Refresh(rawToken string) (*AuthResponse, error) { if rawToken == "" { - return nil, errors.New("refresh token is required") + return nil, serviceError(ErrInvalidInput, "refresh token is required", nil) } - token, ok := a.store.GetRefreshTokenByHash(hashToken(rawToken)) - if !ok { - return nil, errors.New("refresh token is invalid or expired") + + now := time.Now() + replacementRaw, err := generateOpaqueToken() + if err != nil { + return nil, serviceError(ErrInternal, "unable to refresh session", err) } - if token.RevokedAt != nil { - // Suspected replay — burn the whole family. - a.store.RevokeRefreshTokenFamily(token.FamilyID) - return nil, errors.New("refresh token reuse detected; please sign in again") + replacement := models.RefreshToken{ + ID: "rt_" + uuid.NewString(), + TokenHash: hashToken(replacementRaw), + CreatedAt: now, + ExpiresAt: now.Add(a.cfg.RefreshTokenTTL), } - if time.Now().After(token.ExpiresAt) { - _ = a.store.RevokeRefreshToken(token.ID) - return nil, errors.New("refresh token is invalid or expired") + + old, outcome, err := a.store.RotateRefreshToken(hashToken(rawToken), replacement, now) + if err != nil { + return nil, serviceError(ErrDependencyUnavailable, "session storage is unavailable", err) + } + switch outcome { + case store.RefreshTokenNotFound, store.RefreshTokenExpired: + return nil, serviceError(ErrInvalidCredentials, "refresh token is invalid or expired", nil) + case store.RefreshTokenReused: + return nil, serviceError(ErrInvalidCredentials, "refresh token reuse detected; please sign in again", nil) + case store.RefreshTokenGenerationLimit: + return nil, serviceError(ErrInvalidCredentials, "session refresh limit reached; please sign in again", nil) + case store.RefreshTokenRotated: + // Continue below only after the replacement is durably committed. + default: + return nil, serviceError(ErrInternal, "unable to refresh session", errors.New("refresh token rotation returned an invalid outcome")) + } + + user, ok, err := lookupUserByID(a.store, old.UserID) + if err != nil { + a.store.RevokeRefreshTokenFamily(old.FamilyID) + return nil, serviceError(ErrDependencyUnavailable, "account storage is unavailable", err) } - user, ok := a.store.GetUserByID(token.UserID) if !ok { - return nil, errors.New("user not found") + a.store.RevokeRefreshTokenFamily(old.FamilyID) + return nil, serviceError(ErrInternal, "unable to refresh session", errors.New("refresh token references a missing user")) } - if !a.store.RevokeRefreshToken(token.ID) { - a.store.RevokeRefreshTokenFamily(token.FamilyID) - return nil, errors.New("refresh token is invalid or expired") + accessToken, err := a.GenerateToken(user) + if err != nil { + a.store.RevokeRefreshTokenFamily(old.FamilyID) + return nil, serviceError(ErrInternal, "unable to refresh session", err) } - return a.createSessionInFamily(user, token.FamilyID) + return buildAuthResponse(user, accessToken, replacementRaw), nil } -func (a *AuthService) Logout(rawToken string) { +func (a *AuthService) Logout(rawToken string) error { if rawToken == "" { - return + return nil } + if revoker, ok := a.store.(store.ErrorAwareRefreshTokenRevoker); ok { + if _, err := revoker.RevokeRefreshTokenFamilyByHash(hashToken(rawToken), time.Now()); err != nil { + return serviceError(ErrDependencyUnavailable, "session storage is unavailable", err) + } + return nil + } + + // Transitional fallback for external repository adapters that have not yet + // adopted the error-aware capability. Production and memory adapters use the + // atomic path above. if token, ok := a.store.GetRefreshTokenByHash(hashToken(rawToken)); ok { a.store.RevokeRefreshTokenFamily(token.FamilyID) } + return nil } func (a *AuthService) CreateSession(user models.User) (*AuthResponse, error) { @@ -228,21 +317,28 @@ func (a *AuthService) CreateSession(user models.User) (*AuthResponse, error) { func (a *AuthService) createSessionInFamily(user models.User, familyID string) (*AuthResponse, error) { accessToken, err := a.GenerateToken(user) if err != nil { - return nil, err + return nil, serviceError(ErrInternal, "unable to create session", err) } refreshToken, err := generateOpaqueToken() if err != nil { - return nil, err + return nil, serviceError(ErrInternal, "unable to create session", err) } - a.store.SaveRefreshToken(models.RefreshToken{ + now := time.Now() + if err := a.store.SaveRefreshToken(models.RefreshToken{ ID: "rt_" + uuid.NewString(), UserID: user.ID, FamilyID: familyID, TokenHash: hashToken(refreshToken), - CreatedAt: time.Now(), - ExpiresAt: time.Now().Add(a.cfg.RefreshTokenTTL), - }) + CreatedAt: now, + ExpiresAt: now.Add(a.cfg.RefreshTokenTTL), + }); err != nil { + return nil, serviceError(ErrDependencyUnavailable, "session storage is unavailable", err) + } + + return buildAuthResponse(user, accessToken, refreshToken), nil +} +func buildAuthResponse(user models.User, accessToken, refreshToken string) *AuthResponse { resp := &AuthResponse{Token: accessToken, RefreshToken: refreshToken} resp.User.ID = user.ID resp.User.Name = user.Name @@ -251,7 +347,7 @@ func (a *AuthService) createSessionInFamily(user models.User, familyID string) ( resp.User.Avatar = user.Avatar resp.User.AuthProvider = user.AuthProvider resp.User.CreatedAt = user.CreatedAt - return resp, nil + return resp } // IssueAuthCode creates a short-lived, one-time code for OAuth redirect handoff. @@ -259,10 +355,10 @@ func (a *AuthService) createSessionInFamily(user models.User, familyID string) ( func (a *AuthService) IssueAuthCode(user models.User) (string, error) { raw, err := generateOpaqueToken() if err != nil { - return "", err + return "", serviceError(ErrInternal, "unable to initialize sign-in", err) } if err := a.store.SaveAuthCode(hashToken(raw), user.ID, time.Now().Add(authCodeTTL)); err != nil { - return "", err + return "", serviceError(ErrDependencyUnavailable, "authorization storage is unavailable", err) } return raw, nil } @@ -270,19 +366,49 @@ func (a *AuthService) IssueAuthCode(user models.User) (string, error) { // ExchangeAuthCode consumes a one-time code and returns a session pair. func (a *AuthService) ExchangeAuthCode(rawCode string) (*AuthResponse, error) { if rawCode == "" { - return nil, errors.New("authorization code is required") + return nil, serviceError(ErrInvalidInput, "authorization code is required", nil) + } + userID, ok, err := consumeAuthCode(a.store, hashToken(rawCode)) + if err != nil { + return nil, serviceError(ErrDependencyUnavailable, "authorization storage is unavailable", err) } - userID, ok := a.store.ConsumeAuthCode(hashToken(rawCode)) if !ok { - return nil, errors.New("authorization code is invalid or expired") + return nil, serviceError(ErrInvalidCredentials, "authorization code is invalid or expired", nil) + } + user, ok, err := lookupUserByID(a.store, userID) + if err != nil { + return nil, serviceError(ErrDependencyUnavailable, "account storage is unavailable", err) } - user, ok := a.store.GetUserByID(userID) if !ok { - return nil, errors.New("user not found") + return nil, serviceError(ErrInternal, "unable to establish session", errors.New("authorization code references a missing user")) } return a.CreateSession(user) } +func lookupUserByID(repository store.Repository, id string) (models.User, bool, error) { + if lookup, ok := repository.(store.ErrorAwareUserLookup); ok { + return lookup.LookupUserByID(id) + } + user, found := repository.GetUserByID(id) + return user, found, nil +} + +func lookupUserByEmail(repository store.Repository, email string) (models.User, bool, error) { + if lookup, ok := repository.(store.ErrorAwareUserLookup); ok { + return lookup.LookupUserByEmail(email) + } + user, found := repository.GetUserByEmail(email) + return user, found, nil +} + +func consumeAuthCode(repository store.Repository, codeHash string) (string, bool, error) { + if consumer, ok := repository.(store.ErrorAwareAuthCodeConsumer); ok { + return consumer.ConsumeAuthCodeWithError(codeHash) + } + userID, found := repository.ConsumeAuthCode(codeHash) + return userID, found, nil +} + // GenerateToken creates a short-lived access JWT with iss/aud/sub. func (a *AuthService) GenerateToken(user models.User) (string, error) { now := time.Now() diff --git a/interviewhub-backend-go/internal/services/errors.go b/interviewhub-backend-go/internal/services/errors.go new file mode 100644 index 00000000..6758de18 --- /dev/null +++ b/interviewhub-backend-go/internal/services/errors.go @@ -0,0 +1,46 @@ +package services + +import "errors" + +// Service error categories let HTTP handlers distinguish expected client +// failures from unavailable dependencies without exposing persistence details. +var ( + ErrInvalidInput = errors.New("invalid input") + ErrInvalidCredentials = errors.New("invalid credentials") + ErrNotFound = errors.New("resource not found") + ErrConflict = errors.New("resource conflict") + ErrDependencyUnavailable = errors.New("dependency unavailable") + ErrInternal = errors.New("internal service error") +) + +type classifiedServiceError struct { + kind error + publicMessage string + cause error +} + +func (e *classifiedServiceError) Error() string { + if e.cause != nil { + return e.publicMessage + ": " + e.cause.Error() + } + return e.publicMessage +} + +func (e *classifiedServiceError) Is(target error) bool { return target == e.kind } + +func (e *classifiedServiceError) Unwrap() error { return e.cause } + +func serviceError(kind error, publicMessage string, cause error) error { + return &classifiedServiceError{kind: kind, publicMessage: publicMessage, cause: cause} +} + +// PublicErrorMessage returns only a message explicitly classified as safe by +// a service. Handlers should still use a generic fallback for unavailable or +// unexpected errors. +func PublicErrorMessage(err error, fallback string) string { + var classified *classifiedServiceError + if errors.As(err, &classified) && classified.publicMessage != "" { + return classified.publicMessage + } + return fallback +} diff --git a/interviewhub-backend-go/internal/services/execution_evaluation.go b/interviewhub-backend-go/internal/services/execution_evaluation.go new file mode 100644 index 00000000..e0c3784e --- /dev/null +++ b/interviewhub-backend-go/internal/services/execution_evaluation.go @@ -0,0 +1,359 @@ +package services + +import ( + "context" + "fmt" + "strings" + "sync" + "time" + + "github.com/interviewhub/backend/internal/models" +) + +const ( + maxConcurrentTestWorkers = 32 + maxCatalogTestTimeout = 30 * time.Second + localCompilationAllowance = 15 * time.Second +) + +type testCaseOutcome struct { + result models.TestResult + status string + stdout string + stderr string +} + +func (e *ExecutionService) resolveTestCases(problem models.Problem, request models.ExecutionRequest, kind models.ExecutionKind) []models.TestCase { + if kind == models.ExecutionKindRun { + selectedID := strings.TrimSpace(request.TestCaseID) + result := make([]models.TestCase, 0, len(problem.TestCases)) + for _, testCase := range problem.TestCases { + if testCase.Visibility == models.TestHidden { + continue + } + if selectedID != "" && testCase.ID != selectedID { + continue + } + result = append(result, testCase) + } + return result + } + + result := make([]models.TestCase, 0, len(problem.TestCases)+len(problem.HiddenTestCases)) + for _, testCase := range problem.TestCases { + result = append(result, testCase) + } + for _, testCase := range problem.HiddenTestCases { + testCase.Visibility = models.TestHidden + result = append(result, testCase) + } + return result +} + +func (e *ExecutionService) executeTestSuite(problem models.Problem, request models.ExecutionRequest, kind models.ExecutionKind) models.ExecutionResult { + started := time.Now() + testCases := e.resolveTestCases(problem, request, kind) + if len(testCases) == 0 { + return models.ExecutionResult{ + Status: "EVALUATION_UNAVAILABLE", + Stderr: "no server-owned test cases are configured for this execution", + DurationMs: time.Since(started).Milliseconds(), + } + } + + results := make([]models.TestResult, 0, len(testCases)) + visibleStdout := make([]string, 0, len(testCases)) + visibleStderr := make([]string, 0) + overallStatus := "SUCCESS" + passed := 0 + + for index, testCase := range testCases { + outcome := e.executeTestCase(request, testCase, index) + results = append(results, outcome.result) + if outcome.result.Passed { + passed++ + } + overallStatus = dominantExecutionStatus(overallStatus, outcome.status) + if testCase.Visibility != models.TestHidden { + if outcome.stdout != "" { + visibleStdout = append(visibleStdout, formatCaseOutput(testCase.Name, outcome.stdout, len(testCases))) + } + if outcome.stderr != "" { + visibleStderr = append(visibleStderr, formatCaseOutput(testCase.Name, outcome.stderr, len(testCases))) + } + } + // Compilation/runtime availability failures affect every case and retrying + // the same immutable source only wastes bounded worker capacity. + if outcome.status == "COMPILE_ERROR" || outcome.status == "RUNTIME_UNAVAILABLE" { + for remaining := index + 1; remaining < len(testCases); remaining++ { + results = append(results, skippedTestResult(testCases[remaining], remaining, outcome.status)) + } + break + } + } + + return models.ExecutionResult{ + Success: overallStatus == "SUCCESS" && passed == len(testCases), + Status: overallStatus, + Stdout: strings.Join(visibleStdout, "\n"), + Stderr: strings.Join(visibleStderr, "\n"), + DurationMs: time.Since(started).Milliseconds(), + TestResults: results, + PassedTests: passed, + TotalTests: len(testCases), + } +} + +func (e *ExecutionService) executeTestCase(request models.ExecutionRequest, testCase models.TestCase, ordinal int) testCaseOutcome { + workers := testCase.ConcurrentWorkers + if workers < 1 { + workers = 1 + } + if workers > maxConcurrentTestWorkers { + return evaluationConfigurationFailure(testCase, ordinal, fmt.Sprintf("concurrentWorkers exceeds limit of %d", maxConcurrentTestWorkers)) + } + if strings.TrimSpace(testCase.ExpectedOutput) == "" { + return evaluationConfigurationFailure(testCase, ordinal, "test case has no expected output") + } + + timeout := e.catalogTestTimeout(testCase, request.Language) + ctx, cancel := context.WithTimeout(e.ctx, timeout) + defer cancel() + + started := time.Now() + workerResults := make([]models.ExecutionResult, workers) + var waitGroup sync.WaitGroup + for workerIndex := range workers { + waitGroup.Add(1) + go func(index int) { + defer waitGroup.Done() + workerRequest := request + workerRequest.CustomInput = testCase.Input + workerRequest.TestCaseID = "" + workerRequest.RunTests = false + workerResults[index] = e.runProgram(ctx, ProgramRunRequest{ + Request: workerRequest, + APIMethod: testCase.APIMethod, + WorkerIndex: index, + ConcurrentWorkers: workers, + }) + }(workerIndex) + } + waitGroup.Wait() + + status := "SUCCESS" + passed := true + actualValues := make([]string, 0, workers) + errorValues := make([]string, 0) + for workerIndex, execution := range workerResults { + status = dominantExecutionStatus(status, execution.Status) + actualValues = append(actualValues, execution.Stdout) + if execution.Status != "SUCCESS" { + passed = false + message := execution.Stderr + if message == "" { + message = strings.ToLower(strings.ReplaceAll(execution.Status, "_", " ")) + } + errorValues = append(errorValues, fmt.Sprintf("worker %d: %s", workerIndex+1, message)) + continue + } + if !outputMatches(execution.Stdout, testCase.ExpectedOutput) { + passed = false + errorValues = append(errorValues, fmt.Sprintf("worker %d: output did not match expected result", workerIndex+1)) + } + } + + actual := combineWorkerOutput(actualValues) + errorMessage := strings.Join(errorValues, "; ") + result := models.TestResult{ + TestCaseID: testCase.ID, + Name: testCase.Name, + Passed: passed, + DurationMs: time.Since(started).Milliseconds(), + Expected: testCase.ExpectedOutput, + Actual: actual, + Error: errorMessage, + } + if testCase.Visibility == models.TestHidden { + result.TestCaseID = fmt.Sprintf("hidden-%d", ordinal+1) + result.Name = fmt.Sprintf("Hidden test %d", ordinal+1) + result.Expected = "" + result.Actual = "" + if !result.Passed { + result.Error = "hidden test failed" + } + } + return testCaseOutcome{result: result, status: status, stdout: actual, stderr: errorMessage} +} + +func (e *ExecutionService) runProgram(ctx context.Context, runRequest ProgramRunRequest) (result models.ExecutionResult) { + started := time.Now() + defer func() { + if recovered := recover(); recovered != nil { + result = models.ExecutionResult{Status: "RUNTIME_ERROR", Stderr: "execution runner failed unexpectedly"} + } + if result.DurationMs == 0 { + result.DurationMs = time.Since(started).Milliseconds() + } + if ctx.Err() == context.DeadlineExceeded { + result.Success = false + result.Status = "TIMEOUT" + result.Stderr = "time limit exceeded" + } else if ctx.Err() == context.Canceled && result.Status == "" { + result.Success = false + result.Status = "RUNTIME_ERROR" + result.Stderr = "execution cancelled" + } + if result.Status == "" { + result.Success = false + result.Status = "RUNTIME_ERROR" + if result.Stderr == "" { + result.Stderr = "execution runner returned no status" + } + } else if !isKnownRunnerStatus(result.Status) { + result.Success = false + result.Stderr = "execution runner returned an unsupported status" + result.Status = "RUNTIME_ERROR" + } + }() + return e.runner.Run(ctx, runRequest) +} + +func isKnownRunnerStatus(status string) bool { + switch status { + case "SUCCESS", "COMPILE_ERROR", "RUNTIME_ERROR", "TIMEOUT", "OUTPUT_LIMIT_EXCEEDED", "RUNTIME_UNAVAILABLE": + return true + default: + return false + } +} + +func (e *ExecutionService) catalogTestTimeout(testCase models.TestCase, language models.Language) time.Duration { + timeout := time.Duration(testCase.TimeoutMs) * time.Millisecond + if timeout <= 0 { + timeout = e.defaultExecutionTimeout() + } + return e.withLocalCompilationAllowance(timeout, language) +} + +func (e *ExecutionService) directRunTimeout(language models.Language) time.Duration { + return e.withLocalCompilationAllowance(e.defaultExecutionTimeout(), language) +} + +func (e *ExecutionService) withLocalCompilationAllowance(timeout time.Duration, language models.Language) time.Duration { + // The trusted host runner compiles each isolated Go, Java, or C++ sandbox + // before running it. Keep the catalog timeout as the candidate runtime + // budget while allowing a bounded cold-compiler startup window. Production + // execution is disabled for this runner; a future judge adapter should + // enforce separate compile and run budgets itself. + if e.cfg != nil && e.cfg.ExecutionRunner == "local" && requiresLocalCompilation(language) { + timeout += localCompilationAllowance + } + if timeout > maxCatalogTestTimeout { + timeout = maxCatalogTestTimeout + } + return timeout +} + +func requiresLocalCompilation(language models.Language) bool { + switch language { + case models.LangGo, models.LangJava, models.LangCpp: + return true + default: + return false + } +} + +func (e *ExecutionService) defaultExecutionTimeout() time.Duration { + seconds := e.cfg.ExecutionTimeoutSec + if seconds < 1 { + seconds = 6 + } + return time.Duration(seconds) * time.Second +} + +func evaluationConfigurationFailure(testCase models.TestCase, ordinal int, message string) testCaseOutcome { + result := models.TestResult{ + TestCaseID: testCase.ID, + Name: testCase.Name, + Expected: testCase.ExpectedOutput, + Error: message, + } + if testCase.Visibility == models.TestHidden { + result.TestCaseID = fmt.Sprintf("hidden-%d", ordinal+1) + result.Name = fmt.Sprintf("Hidden test %d", ordinal+1) + result.Expected = "" + result.Error = "hidden test failed" + } + return testCaseOutcome{result: result, status: "EVALUATION_UNAVAILABLE", stderr: message} +} + +func skippedTestResult(testCase models.TestCase, ordinal int, status string) models.TestResult { + message := "not run because source execution cannot continue after " + strings.ToLower(strings.ReplaceAll(status, "_", " ")) + result := models.TestResult{ + TestCaseID: testCase.ID, + Name: testCase.Name, + Expected: testCase.ExpectedOutput, + Error: message, + } + if testCase.Visibility == models.TestHidden { + result.TestCaseID = fmt.Sprintf("hidden-%d", ordinal+1) + result.Name = fmt.Sprintf("Hidden test %d", ordinal+1) + result.Expected = "" + result.Error = "hidden test failed" + } + return result +} + +func dominantExecutionStatus(current, candidate string) string { + priority := map[string]int{ + "SUCCESS": 0, + "RUNTIME_ERROR": 1, + "OUTPUT_LIMIT_EXCEEDED": 2, + "TIMEOUT": 3, + "COMPILE_ERROR": 4, + "RUNTIME_UNAVAILABLE": 5, + "EVALUATION_UNAVAILABLE": 6, + } + if priority[candidate] > priority[current] { + return candidate + } + return current +} + +func combineWorkerOutput(values []string) string { + if len(values) == 0 { + return "" + } + allEqual := true + for index := 1; index < len(values); index++ { + if normalizeOutput(values[index]) != normalizeOutput(values[0]) { + allEqual = false + break + } + } + if allEqual { + return values[0] + } + parts := make([]string, 0, len(values)) + for index, value := range values { + parts = append(parts, fmt.Sprintf("[worker %d]\n%s", index+1, value)) + } + return strings.Join(parts, "\n") +} + +func formatCaseOutput(name, value string, totalCases int) string { + if totalCases == 1 { + return value + } + return fmt.Sprintf("[%s]\n%s", name, value) +} + +func outputMatches(actual, expected string) bool { + expected = normalizeOutput(expected) + return expected != "" && normalizeOutput(actual) == expected +} + +func normalizeOutput(value string) string { + return strings.TrimSpace(strings.ReplaceAll(value, "\r\n", "\n")) +} diff --git a/interviewhub-backend-go/internal/services/execution_service.go b/interviewhub-backend-go/internal/services/execution_service.go index 8976f1cc..da3443f6 100644 --- a/interviewhub-backend-go/internal/services/execution_service.go +++ b/interviewhub-backend-go/internal/services/execution_service.go @@ -1,14 +1,11 @@ package services import ( - "bytes" "context" + "crypto/sha256" + "encoding/json" + "errors" "fmt" - "math/rand" - "os" - "os/exec" - "path/filepath" - "strings" "sync" "time" @@ -18,52 +15,204 @@ import ( "github.com/interviewhub/backend/internal/store" ) +var ( + ErrExecutionDisabled = errors.New("code execution is disabled") + ErrExecutionQueueFull = errors.New("execution queue is full") + ErrExecutionUserSaturated = errors.New("too many active executions for this user") + ErrExecutionServiceClosed = errors.New("execution service is closed") + ErrSubmissionNotConfigured = errors.New("submission recorder is not configured") +) + +const ( + maxActiveExecutionsPerUser = 4 + maxRetainedExecutionJobs = 1024 + executionJobRetentionTTL = 30 * time.Minute + executionShutdownError = "execution stopped because the service is shutting down" +) + +// SubmissionRecorder persists one terminal submit result. Keeping this small +// interface inverts the dependency: submission recording depends on a result, +// while SubmissionService no longer depends on or invokes ExecutionService. +type SubmissionRecorder interface { + RecordCompletedSubmission(userID string, request models.ExecutionRequest, problem models.Problem, evaluationRevision string, result models.ExecutionResult) (models.Submission, error) +} + +// ProgramRunRequest is the trusted execution specification supplied to a +// runner. Catalog-owned method and concurrency metadata never come from the +// candidate request body. +type ProgramRunRequest struct { + Request models.ExecutionRequest + APIMethod string + WorkerIndex int + ConcurrentWorkers int +} + +// ProgramRunner is the replaceable boundary around candidate process +// execution. Production must provide an isolated runner; localProgramRunner is +// intentionally limited to explicitly enabled local development. +type ProgramRunner interface { + Run(context.Context, ProgramRunRequest) models.ExecutionResult +} + type ExecutionService struct { - store store.Repository - cfg *config.Config + store store.Repository + cfg *config.Config + runner ProgramRunner + + mu sync.RWMutex + jobs map[string]models.ExecutionJob + queue chan string + closed bool + + recorderMu sync.RWMutex + recorder SubmissionRecorder + + ctx context.Context + cancel context.CancelFunc + workers sync.WaitGroup + closeOnce sync.Once +} + +func NewExecutionService(repository store.Repository, cfg *config.Config) *ExecutionService { + return NewExecutionServiceWithRunner(repository, cfg, &localProgramRunner{cfg: cfg}) +} - mu sync.RWMutex - jobs map[string]*models.ExecutionJob +// NewExecutionServiceWithRunner is primarily useful for deterministic tests +// and for a future isolated judge adapter. +func NewExecutionServiceWithRunner(repository store.Repository, cfg *config.Config, runner ProgramRunner) *ExecutionService { + queueCapacity := cfg.ExecutionQueueCapacity + if queueCapacity < 1 { + queueCapacity = 32 + } + workerCount := cfg.ExecutionWorkerCount + if workerCount < 1 { + workerCount = 2 + } + + ctx, cancel := context.WithCancel(context.Background()) + service := &ExecutionService{ + store: repository, + cfg: cfg, + runner: runner, + jobs: make(map[string]models.ExecutionJob), + queue: make(chan string, queueCapacity), + ctx: ctx, + cancel: cancel, + } + for range workerCount { + service.workers.Add(1) + go service.worker() + } + return service +} + +func (e *ExecutionService) SetSubmissionRecorder(recorder SubmissionRecorder) { + e.recorderMu.Lock() + e.recorder = recorder + e.recorderMu.Unlock() } -func NewExecutionService(s store.Repository, cfg *config.Config) *ExecutionService { - return &ExecutionService{store: s, cfg: cfg, jobs: make(map[string]*models.ExecutionJob)} +// Close cancels active local work and waits for worker goroutines to exit. +func (e *ExecutionService) Close() { + e.closeOnce.Do(func() { + e.mu.Lock() + e.closed = true + now := time.Now().UTC() + for id, job := range e.jobs { + if job.Status != models.ExecutionStatusQueued { + continue + } + markExecutionStoppedByShutdown(&job, now) + e.jobs[id] = job + } + e.cancel() + e.mu.Unlock() + e.workers.Wait() + }) } -// ExecuteCode preserves the legacy synchronous execution contract for callers that still expect instant results. -func (e *ExecutionService) ExecuteCode(req models.ExecutionRequest) models.ExecutionResult { - result := e.executeInternal(req, models.ExecutionKindRun) - return result +// ExecuteCode preserves the synchronous contract for non-HTTP callers. New +// request paths should use QueueExecution and poll GetExecution. +func (e *ExecutionService) ExecuteCode(request models.ExecutionRequest) models.ExecutionResult { + request.TestCases = nil + problem, err := e.validateExecutionRequest(request, models.ExecutionKindRun, "") + if err != nil { + return models.ExecutionResult{Status: "INVALID_REQUEST", Stderr: err.Error()} + } + return e.executeInternal(request, models.ExecutionKindRun, problem) } -// QueueExecution creates a job record, queues it for asynchronous execution, and returns its id. -func (e *ExecutionService) QueueExecution(req models.ExecutionRequest, kind models.ExecutionKind, userID string) (models.ExecutionJob, error) { +// QueueExecution validates and enqueues work without ever spawning an +// unbounded request goroutine. A full queue is rejected immediately. +func (e *ExecutionService) QueueExecution(request models.ExecutionRequest, kind models.ExecutionKind, userID string) (models.ExecutionJob, error) { + request.TestCases = nil + problem, err := e.validateExecutionRequest(request, kind, userID) + if err != nil { + return models.ExecutionJob{}, err + } + if kind == models.ExecutionKindSubmit && e.submissionRecorder() == nil { + return models.ExecutionJob{}, ErrSubmissionNotConfigured + } + + now := time.Now().UTC() job := models.ExecutionJob{ - ID: "exec_" + uuid.New().String()[:8], - Kind: kind, - UserID: userID, - Request: req, - Status: models.ExecutionStatusQueued, - CreatedAt: time.Now(), - UpdatedAt: time.Now(), + ID: "exec_" + uuid.NewString(), + Kind: kind, + UserID: userID, + Request: cloneExecutionRequest(request), + EvaluationProblem: problem, + EvaluationRevision: evaluationRevision(problem), + Status: models.ExecutionStatusQueued, + CreatedAt: now, + UpdatedAt: now, } e.mu.Lock() - e.jobs[job.ID] = &job - e.mu.Unlock() + defer e.mu.Unlock() + if e.closed { + return models.ExecutionJob{}, ErrExecutionServiceClosed + } + e.pruneTerminalJobsLocked(now) + if userID != "" && e.activeExecutionsForUserLocked(userID) >= maxActiveExecutionsPerUser { + return models.ExecutionJob{}, ErrExecutionUserSaturated + } + e.jobs[job.ID] = job + select { + case e.queue <- job.ID: + return cloneExecutionJob(job), nil + default: + delete(e.jobs, job.ID) + return models.ExecutionJob{}, ErrExecutionQueueFull + } +} - go e.processJob(job.ID) - return job, nil +func evaluationRevision(problem models.Problem) string { + // Empty and nil case lists are semantically identical. Canonicalize both to + // JSON arrays so copying a snapshot cannot change its revision hash. + publicCases := append([]models.TestCase{}, problem.TestCases...) + hiddenCases := append([]models.TestCase{}, problem.HiddenTestCases...) + payload, err := json.Marshal(struct { + ID string `json:"id"` + UpdatedAt time.Time `json:"updatedAt"` + Public []models.TestCase `json:"public"` + Hidden []models.TestCase `json:"hidden"` + }{problem.ID, problem.UpdatedAt.UTC(), publicCases, hiddenCases}) + if err != nil { + return "unavailable" + } + digest := sha256.Sum256(payload) + return fmt.Sprintf("sha256:%x", digest[:]) } func (e *ExecutionService) GetExecution(id string) (models.ExecutionJob, bool) { - e.mu.RLock() + e.mu.Lock() + e.pruneTerminalJobsLocked(time.Now().UTC()) job, ok := e.jobs[id] - e.mu.RUnlock() + e.mu.Unlock() if !ok { return models.ExecutionJob{}, false } - return *job, true + return cloneExecutionJob(job), true } func (e *ExecutionService) Metrics() map[string]any { @@ -86,305 +235,19 @@ func (e *ExecutionService) Metrics() map[string]any { } } return map[string]any{ - "queueDepth": queued + running, - "queued": queued, - "running": running, - "completed": completed, - "failed": failed, + "enabled": e.cfg.ExecutionEnabled, + "queueDepth": queued + running, + "queueCapacity": cap(e.queue), + "queued": queued, + "running": running, + "completed": completed, + "failed": failed, } } -func (e *ExecutionService) Ready() bool { return true } - -func (e *ExecutionService) processJob(id string) { +func (e *ExecutionService) Ready() bool { e.mu.RLock() - job, ok := e.jobs[id] + closed := e.closed e.mu.RUnlock() - if !ok { - return - } - - e.mu.Lock() - job.Status = models.ExecutionStatusRunning - job.UpdatedAt = time.Now() - e.mu.Unlock() - - kind := job.Kind - result := e.executeInternal(job.Request, kind) - - e.mu.Lock() - job.Result = &result - job.UpdatedAt = time.Now() - switch result.Status { - case "SUCCESS": - job.Status = models.ExecutionStatusCompleted - case "TIMEOUT": - job.Status = models.ExecutionStatusTimedOut - default: - job.Status = models.ExecutionStatusFailed - } - e.mu.Unlock() -} - -func (e *ExecutionService) executeInternal(req models.ExecutionRequest, kind models.ExecutionKind) models.ExecutionResult { - startTime := time.Now() - sandboxID := "sbx_" + uuid.New().String()[:8] - sandboxDir := filepath.Join(e.cfg.SandboxTempDir, sandboxID) - - defer func() { - _ = os.RemoveAll(sandboxDir) - }() - - if err := os.MkdirAll(sandboxDir, 0755); err != nil { - return models.ExecutionResult{Success: false, Status: "FAILED", Stderr: "Failed to initialize sandbox directory: " + err.Error(), DurationMs: time.Since(startTime).Milliseconds()} - } - - if err := e.ensureExecutionLimits(req); err != nil { - return models.ExecutionResult{Success: false, Status: "FAILED", Stderr: err.Error(), DurationMs: time.Since(startTime).Milliseconds()} - } - - if err := e.writeFilesRecursively(sandboxDir, req.Files); err != nil { - return models.ExecutionResult{Success: false, Status: "FAILED", Stderr: "Failed to stage files: " + err.Error(), DurationMs: time.Since(startTime).Milliseconds()} - } - - var testCases []models.TestCase - if len(req.TestCases) > 0 { - testCases = req.TestCases - } else { - testCases = e.resolveTestCases(req, kind) - } - - ctx, cancel := context.WithTimeout(context.Background(), time.Duration(e.cfg.ExecutionTimeoutSec)*time.Second) - defer cancel() - - cmdStr, args := e.buildCommand(req.Language) - cmd := exec.CommandContext(ctx, cmdStr, args...) - cmd.Dir = sandboxDir - - var stdoutBuf, stderrBuf bytes.Buffer - cmd.Stdout = &stdoutBuf - cmd.Stderr = &stderrBuf - - err := cmd.Run() - durationMs := time.Since(startTime).Milliseconds() - - status := "SUCCESS" - stdout := strings.TrimSpace(stdoutBuf.String()) - stderr := strings.TrimSpace(stderrBuf.String()) - - if ctx.Err() == context.DeadlineExceeded { - status = "TIMEOUT" - stderr = fmt.Sprintf("Time Limit Exceeded (%ds). Check for infinite loops or deadlocks.", e.cfg.ExecutionTimeoutSec) - } else if err != nil { - if strings.Contains(stderr, "not found") || strings.Contains(err.Error(), "executable file not found") { - status = "SUCCESS" - stdout = e.generateSimulatedOutput(req.Language, req.Files) - stderr = "" - } else { - if strings.Contains(stderr, "error:") || strings.Contains(stderr, "SyntaxError") || strings.Contains(stderr, "cannot find symbol") { - status = "COMPILE_ERROR" - } else { - status = "RUNTIME_ERROR" - } - if stderr == "" { - stderr = err.Error() - } - } - } - - if stdout == "" && status == "SUCCESS" { - stdout = "✓ Execution completed with exit code 0 (no output produced)." - } - - if len(stdout) > int(e.cfg.ExecutionMaxOutputBytes) { - stdout = stdout[:int(e.cfg.ExecutionMaxOutputBytes)] + "\n...output truncated" - status = "FAILED" - stderr = "Output exceeded the configured limit" - } - - testResults := e.evaluateTestCases(testCases, status, stderr) - passedTests := 0 - for _, tr := range testResults { - if tr.Passed { - passedTests++ - } - } - - return models.ExecutionResult{ - Success: status == "SUCCESS", - Status: status, - Stdout: stdout, - Stderr: stderr, - DurationMs: durationMs, - TestResults: testResults, - PassedTests: passedTests, - TotalTests: len(testResults), - } -} - -func (e *ExecutionService) ensureExecutionLimits(req models.ExecutionRequest) error { - fileCount := 0 - var walk func([]models.FileNode) - walk = func(nodes []models.FileNode) { - for _, node := range nodes { - fileCount++ - if len(node.Children) > 0 { - walk(node.Children) - } - } - } - walk(req.Files) - if fileCount > e.cfg.ExecutionMaxFileCount { - return fmt.Errorf("execution file count exceeds limit of %d", e.cfg.ExecutionMaxFileCount) - } - return nil -} - -func (e *ExecutionService) resolveTestCases(req models.ExecutionRequest, kind models.ExecutionKind) []models.TestCase { - if req.ProblemID == "" { - return nil - } - problem, ok := e.store.GetProblemByID(req.ProblemID) - if !ok { - return nil - } - if kind == models.ExecutionKindSubmit { - cases := append([]models.TestCase{}, problem.TestCases...) - cases = append(cases, problem.HiddenTestCases...) - return cases - } - return problem.TestCases -} - -func (e *ExecutionService) writeFilesRecursively(baseDir string, nodes []models.FileNode) error { - for _, node := range nodes { - targetPath, err := safeSandboxPath(baseDir, node.Path) - if err != nil { - return err - } - if node.IsDirectory { - if err := os.MkdirAll(targetPath, 0755); err != nil { - return err - } - if len(node.Children) > 0 { - if err := e.writeFilesRecursively(targetPath, node.Children); err != nil { - return err - } - } - } else { - if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil { - return err - } - if err := os.WriteFile(targetPath, []byte(node.Content), 0644); err != nil { - return err - } - } - } - return nil -} - -func safeSandboxPath(baseDir, relativePath string) (string, error) { - clean := filepath.Clean(relativePath) - if clean == "." || clean == string(filepath.Separator) || filepath.IsAbs(clean) || strings.HasPrefix(clean, "..") { - return "", fmt.Errorf("invalid sandbox path %q", relativePath) - } - return filepath.Join(baseDir, clean), nil -} - -func (e *ExecutionService) buildCommand(lang models.Language) (string, []string) { - switch lang { - case models.LangPython: - return "python3", []string{"src/main.py"} - case models.LangJava: - return "sh", []string{"-c", "mkdir -p out && javac -d out $(find src -name '*.java') && java -cp out Main"} - case models.LangTypeScript: - return "sh", []string{"-c", "npx -y tsx src/Main.ts"} - case models.LangCpp: - return "sh", []string{"-c", "mkdir -p out && g++ -std=c++17 -o out/app $(find src -name '*.cpp') && ./out/app"} - case models.LangGo: - return "sh", []string{"-c", "go run src/main.go"} - default: - return "python3", []string{"src/main.py"} - } -} - -func (e *ExecutionService) generateSimulatedOutput(lang models.Language, files []models.FileNode) string { - var sb strings.Builder - sb.WriteString(fmt.Sprintf("=== [Go Sandbox Runtime: %s] ===\n", strings.ToUpper(string(lang)))) - sb.WriteString("✓ Multi-file project compiled successfully with zero warnings.\n\n") - - content := e.collectFileContents(files) - - if strings.Contains(content, "Parking") || strings.Contains(content, "Vehicle") { - sb.WriteString("=== Initializing Parking Lot System ===\n") - sb.WriteString("Parked Car: Ticket ID = TKT-89ab12cd at Spot: F1-S1\n") - sb.WriteString("Parked Bike: Ticket ID = TKT-44ef56gh at Spot: F1-S2\n") - sb.WriteString("Available Spots: 68 / 70\n") - sb.WriteString("Payment Processed: $20.0 | Status: SUCCESS\n") - } else if strings.Contains(content, "BookMyShow") || strings.Contains(content, "Seat") { - sb.WriteString("=== Initializing Movie Ticket Booking System (BookMyShow) ===\n") - sb.WriteString("Found Show SHOW-1 for Interstellar in New York\n") - sb.WriteString("Seats Locked Successfully. Lock Token: LCK-7a8b9c\n") - sb.WriteString("Booking Confirmed! Booking ID: BKG-314159\n") - } else if strings.Contains(content, "RateLimiter") || strings.Contains(content, "Bucket") { - sb.WriteString("=== Initializing Distributed Rate Limiter ===\n") - sb.WriteString("Request #1 -> Allowed: true\n") - sb.WriteString("Request #2 -> Allowed: true\n") - sb.WriteString("Request #3 -> Allowed: true\n") - sb.WriteString("Request #4 -> Allowed: true\n") - sb.WriteString("Request #5 -> Allowed: true\n") - sb.WriteString("Request #6 -> Allowed: false [Throttled: Quota Exceeded]\n") - } else if strings.Contains(content, "LRUCache") || strings.Contains(content, "Cache") { - sb.WriteString("=== Initializing LRU Cache (Capacity = 3) ===\n") - sb.WriteString("Get u1: 100\n") - sb.WriteString("Get u2 (evicted): null\n") - sb.WriteString("Get u3: 300\n") - } else { - sb.WriteString("=== System Execution ===\n") - sb.WriteString("✓ All low-level components initialized and executed cleanly.\n") - } - - return sb.String() -} - -func (e *ExecutionService) collectFileContents(nodes []models.FileNode) string { - var sb strings.Builder - for _, n := range nodes { - sb.WriteString(n.Content) - if len(n.Children) > 0 { - sb.WriteString(e.collectFileContents(n.Children)) - } - } - return sb.String() -} - -func (e *ExecutionService) evaluateTestCases(testCases []models.TestCase, status, stderr string) []models.TestResult { - var results []models.TestResult - - for _, tc := range testCases { - if status != "SUCCESS" { - results = append(results, models.TestResult{ - TestCaseID: tc.ID, - Name: tc.Name, - Passed: false, - DurationMs: 0, - Expected: tc.ExpectedOutput, - Actual: "", - Error: stderr, - }) - continue - } - - duration := int64(10 + rand.Intn(25)) - results = append(results, models.TestResult{ - TestCaseID: tc.ID, - Name: tc.Name, - Passed: true, - DurationMs: duration, - Expected: tc.ExpectedOutput, - Actual: tc.ExpectedOutput, - }) - } - - return results + return e.cfg.ExecutionEnabled && e.cfg.ExecutionRunner != "disabled" && e.runner != nil && !closed } diff --git a/interviewhub-backend-go/internal/services/execution_service_test.go b/interviewhub-backend-go/internal/services/execution_service_test.go new file mode 100644 index 00000000..ffa3d99f --- /dev/null +++ b/interviewhub-backend-go/internal/services/execution_service_test.go @@ -0,0 +1,719 @@ +package services + +import ( + "context" + "errors" + "fmt" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/google/uuid" + "github.com/interviewhub/backend/internal/config" + "github.com/interviewhub/backend/internal/models" + "github.com/interviewhub/backend/internal/store" +) + +type runnerFunc func(context.Context, ProgramRunRequest) models.ExecutionResult + +func (f runnerFunc) Run(ctx context.Context, request ProgramRunRequest) models.ExecutionResult { + return f(ctx, request) +} + +type submissionRecorderFunc func(string, models.ExecutionRequest, models.Problem, string, models.ExecutionResult) (models.Submission, error) + +func (f submissionRecorderFunc) RecordCompletedSubmission(userID string, request models.ExecutionRequest, problem models.Problem, revision string, result models.ExecutionResult) (models.Submission, error) { + return f(userID, request, problem, revision, result) +} + +func executionConfig() *config.Config { + return &config.Config{ + ExecutionEnabled: true, + ExecutionRunner: "local", + ExecutionQueueCapacity: 128, + ExecutionWorkerCount: 4, + ExecutionTimeoutSec: 2, + ExecutionMaxFileCount: 20, + ExecutionMaxOutputBytes: 1024, + } +} + +func executionRequest(problemID string) models.ExecutionRequest { + return models.ExecutionRequest{ + ProblemID: problemID, + Language: models.LangGo, + Files: []models.FileNode{{ + ID: "main", + Name: "main.go", + Path: "src/main.go", + Language: models.LangGo, + Content: "package main\nfunc main() {}", + }}, + } +} + +func TestCatalogTimeoutSeparatesLocalCompilationAllowance(t *testing.T) { + service := &ExecutionService{cfg: executionConfig()} + testCase := models.TestCase{TimeoutMs: 5_000} + + if got, want := service.catalogTestTimeout(testCase, models.LangGo), 20*time.Second; got != want { + t.Fatalf("Go catalog timeout=%v, want runtime plus compile allowance %v", got, want) + } + if got, want := service.catalogTestTimeout(testCase, models.LangPython), 5*time.Second; got != want { + t.Fatalf("Python catalog timeout=%v, want unchanged runtime budget %v", got, want) + } + + service.cfg.ExecutionRunner = "disabled" + if got, want := service.catalogTestTimeout(testCase, models.LangJava), 5*time.Second; got != want { + t.Fatalf("non-local catalog timeout=%v, want unchanged runtime budget %v", got, want) + } + service.cfg.ExecutionRunner = "local" + if got, want := service.catalogTestTimeout(models.TestCase{TimeoutMs: 25_000}, models.LangCpp), maxCatalogTestTimeout; got != want { + t.Fatalf("bounded C++ catalog timeout=%v, want cap %v", got, want) + } +} + +func TestPlainCompiledRunReceivesLocalCompilationAllowance(t *testing.T) { + repository := store.NewMemoryStore() + addExecutionProblem(repository, "plain-compile-budget", nil, nil) + remaining := make(chan time.Duration, 1) + service := NewExecutionServiceWithRunner(repository, executionConfig(), runnerFunc(func(ctx context.Context, _ ProgramRunRequest) models.ExecutionResult { + deadline, ok := ctx.Deadline() + if !ok { + remaining <- 0 + } else { + remaining <- time.Until(deadline) + } + return models.ExecutionResult{Success: true, Status: "SUCCESS"} + })) + defer service.Close() + + job, err := service.QueueExecution(executionRequest("plain-compile-budget"), models.ExecutionKindRun, "usr_compile") + if err != nil { + t.Fatalf("queue plain compiled run: %v", err) + } + terminal := waitForTerminalJob(t, service, job.ID) + if terminal.Status != models.ExecutionStatusCompleted { + t.Fatalf("plain compiled run status=%s, want completed", terminal.Status) + } + if got, wantMinimum := <-remaining, 16*time.Second; got < wantMinimum { + t.Fatalf("plain compiled run budget=%v, want at least %v", got, wantMinimum) + } +} + +func addExecutionProblem(repository *store.Store, id string, testCases, hidden []models.TestCase) { + _, err := repository.SaveProblem(models.Problem{ + ID: id, + Title: "Execution Test", + Difficulty: models.DifficultyEasy, + TestCases: testCases, + HiddenTestCases: hidden, + }) + if err != nil { + panic(err) + } +} + +func waitForTerminalJob(t *testing.T, service *ExecutionService, id string) models.ExecutionJob { + t.Helper() + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + job, ok := service.GetExecution(id) + if !ok { + t.Fatalf("execution %s disappeared", id) + } + switch job.Status { + case models.ExecutionStatusCompleted, models.ExecutionStatusFailed, models.ExecutionStatusTimedOut: + return job + } + time.Sleep(time.Millisecond) + } + t.Fatalf("execution %s did not reach a terminal state", id) + return models.ExecutionJob{} +} + +func TestExecutionJobLifecycleIsRaceSafe(t *testing.T) { + repository := store.NewMemoryStore() + addExecutionProblem(repository, "race-test", nil, nil) + service := NewExecutionServiceWithRunner(repository, executionConfig(), runnerFunc(func(context.Context, ProgramRunRequest) models.ExecutionResult { + return models.ExecutionResult{Success: true, Status: "SUCCESS", Stdout: "ok"} + })) + defer service.Close() + + const jobCount = 64 + ids := make(chan string, jobCount) + var waitGroup sync.WaitGroup + for index := range jobCount { + waitGroup.Add(1) + go func(userID string) { + defer waitGroup.Done() + job, err := service.QueueExecution(executionRequest("race-test"), models.ExecutionKindRun, userID) + if err != nil { + t.Errorf("queue execution: %v", err) + return + } + if _, err := uuid.Parse(strings.TrimPrefix(job.ID, "exec_")); err != nil { + t.Errorf("execution id does not contain a full UUID: %q", job.ID) + } + ids <- job.ID + }(fmt.Sprintf("race-user-%d", index)) + } + waitGroup.Wait() + close(ids) + + for id := range ids { + job := waitForTerminalJob(t, service, id) + if job.Status != models.ExecutionStatusCompleted || job.Result == nil || job.Result.Status != "SUCCESS" { + t.Fatalf("unexpected terminal job: %+v", job) + } + } +} + +func TestExecutionValidationRejectsInvalidRequests(t *testing.T) { + repository := store.NewMemoryStore() + addExecutionProblem(repository, "validation-test", []models.TestCase{{ID: "public-case", Visibility: models.TestPublic}}, nil) + var runs atomic.Int32 + service := NewExecutionServiceWithRunner(repository, executionConfig(), runnerFunc(func(context.Context, ProgramRunRequest) models.ExecutionResult { + runs.Add(1) + return models.ExecutionResult{Success: true, Status: "SUCCESS"} + })) + defer service.Close() + + cases := []struct { + name string + request models.ExecutionRequest + kind models.ExecutionKind + userID string + }{ + {name: "missing problem", request: executionRequest("does-not-exist"), kind: models.ExecutionKindRun}, + {name: "unsupported language", request: func() models.ExecutionRequest { + request := executionRequest("validation-test") + request.Language = "ruby" + return request + }(), kind: models.ExecutionKindRun}, + {name: "no files", request: func() models.ExecutionRequest { + request := executionRequest("validation-test") + request.Files = nil + return request + }(), kind: models.ExecutionKindRun}, + {name: "path traversal", request: func() models.ExecutionRequest { + request := executionRequest("validation-test") + request.Files[0].Path = "../main.go" + return request + }(), kind: models.ExecutionKindRun}, + {name: "duplicate paths", request: func() models.ExecutionRequest { + request := executionRequest("validation-test") + request.Files = append(request.Files, request.Files[0]) + return request + }(), kind: models.ExecutionKindRun}, + {name: "submit without user", request: executionRequest("validation-test"), kind: models.ExecutionKindSubmit}, + {name: "unknown selected case", request: func() models.ExecutionRequest { + request := executionRequest("validation-test") + request.TestCaseID = "missing" + return request + }(), kind: models.ExecutionKindRun}, + {name: "conflicting run selection", request: func() models.ExecutionRequest { + request := executionRequest("validation-test") + request.TestCaseID = "public-case" + request.RunTests = true + return request + }(), kind: models.ExecutionKindRun}, + {name: "submit rejects run tests", request: func() models.ExecutionRequest { + request := executionRequest("validation-test") + request.RunTests = true + return request + }(), kind: models.ExecutionKindSubmit, userID: "usr_cand_01"}, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + if _, err := service.QueueExecution(testCase.request, testCase.kind, testCase.userID); err == nil { + t.Fatal("expected validation error") + } + }) + } + if got := runs.Load(); got != 0 { + t.Fatalf("runner invoked %d times for rejected requests", got) + } +} + +func TestExecutionQueueRejectsWhenFull(t *testing.T) { + repository := store.NewMemoryStore() + addExecutionProblem(repository, "queue-test", nil, nil) + cfg := executionConfig() + cfg.ExecutionQueueCapacity = 1 + cfg.ExecutionWorkerCount = 1 + started := make(chan struct{}, 1) + release := make(chan struct{}) + service := NewExecutionServiceWithRunner(repository, cfg, runnerFunc(func(ctx context.Context, _ ProgramRunRequest) models.ExecutionResult { + select { + case started <- struct{}{}: + default: + } + select { + case <-release: + return models.ExecutionResult{Success: true, Status: "SUCCESS"} + case <-ctx.Done(): + return models.ExecutionResult{Status: "RUNTIME_ERROR", Stderr: ctx.Err().Error()} + } + })) + defer service.Close() + + if _, err := service.QueueExecution(executionRequest("queue-test"), models.ExecutionKindRun, "usr_cand_01"); err != nil { + t.Fatalf("queue first job: %v", err) + } + select { + case <-started: + case <-time.After(time.Second): + t.Fatal("worker did not start first job") + } + if _, err := service.QueueExecution(executionRequest("queue-test"), models.ExecutionKindRun, "usr_cand_01"); err != nil { + t.Fatalf("queue waiting job: %v", err) + } + if _, err := service.QueueExecution(executionRequest("queue-test"), models.ExecutionKindRun, "usr_cand_01"); !errors.Is(err, ErrExecutionQueueFull) { + t.Fatalf("queue overflow error=%v, want ErrExecutionQueueFull", err) + } + close(release) +} + +func TestExecutionCloseDoesNotDrainQueuedJobs(t *testing.T) { + repository := store.NewMemoryStore() + addExecutionProblem(repository, "shutdown-queue-test", nil, nil) + cfg := executionConfig() + cfg.ExecutionQueueCapacity = 8 + cfg.ExecutionWorkerCount = 1 + + started := make(chan struct{}) + var runs atomic.Int32 + service := NewExecutionServiceWithRunner(repository, cfg, runnerFunc(func(ctx context.Context, _ ProgramRunRequest) models.ExecutionResult { + if runs.Add(1) == 1 { + close(started) + } + <-ctx.Done() + return models.ExecutionResult{Status: "RUNTIME_ERROR", Stderr: ctx.Err().Error()} + })) + + active, err := service.QueueExecution(executionRequest("shutdown-queue-test"), models.ExecutionKindRun, "active-user") + if err != nil { + t.Fatalf("queue active job: %v", err) + } + select { + case <-started: + case <-time.After(time.Second): + t.Fatal("worker did not start active job") + } + + queued, err := service.QueueExecution(executionRequest("shutdown-queue-test"), models.ExecutionKindRun, "queued-user") + if err != nil { + t.Fatalf("queue waiting job: %v", err) + } + + var closeCallers sync.WaitGroup + for range 8 { + closeCallers.Add(1) + go func() { + defer closeCallers.Done() + service.Close() + }() + } + closed := make(chan struct{}) + go func() { + closeCallers.Wait() + close(closed) + }() + select { + case <-closed: + case <-time.After(time.Second): + t.Fatal("Close did not stop workers promptly") + } + + if got := runs.Load(); got != 1 { + t.Fatalf("runner invoked %d times, want only the active job", got) + } + for _, id := range []string{active.ID, queued.ID} { + job, ok := service.GetExecution(id) + if !ok { + t.Fatalf("execution %s disappeared during shutdown", id) + } + if job.Status != models.ExecutionStatusFailed || job.Error != executionShutdownError { + t.Fatalf("shutdown job=%+v, want service-level failure", job) + } + if job.Result != nil || job.Submission != nil { + t.Fatalf("shutdown job retained a candidate result or submission: %+v", job) + } + } + + // Close remains safe when lifecycle owners call it more than once. + service.Close() +} + +func TestExecutionCloseDoesNotPersistCanceledSubmit(t *testing.T) { + repository := store.NewMemoryStore() + if _, err := repository.CreateUser(models.User{ID: "candidate-user", Email: "candidate@example.com", Role: models.RoleCandidate}); err != nil { + t.Fatalf("create submit user: %v", err) + } + addExecutionProblem(repository, "shutdown-submit-test", []models.TestCase{{ + ID: "public-case", + Name: "Public case", + Visibility: models.TestPublic, + Input: "input", + ExpectedOutput: "output", + }}, nil) + cfg := executionConfig() + cfg.ExecutionWorkerCount = 1 + + started := make(chan struct{}) + service := NewExecutionServiceWithRunner(repository, cfg, runnerFunc(func(ctx context.Context, _ ProgramRunRequest) models.ExecutionResult { + close(started) + <-ctx.Done() + return models.ExecutionResult{Status: "RUNTIME_ERROR", Stderr: ctx.Err().Error()} + })) + var recorded atomic.Int32 + service.SetSubmissionRecorder(submissionRecorderFunc(func(string, models.ExecutionRequest, models.Problem, string, models.ExecutionResult) (models.Submission, error) { + recorded.Add(1) + return models.Submission{ID: "unexpected-submission"}, nil + })) + + queued, err := service.QueueExecution(executionRequest("shutdown-submit-test"), models.ExecutionKindSubmit, "candidate-user") + if err != nil { + t.Fatalf("queue submit: %v", err) + } + select { + case <-started: + case <-time.After(time.Second): + t.Fatal("submit runner did not start") + } + + service.Close() + if got := recorded.Load(); got != 0 { + t.Fatalf("submission recorder called %d times for shutdown-canceled work", got) + } + job, ok := service.GetExecution(queued.ID) + if !ok { + t.Fatal("shutdown submit disappeared") + } + if job.Status != models.ExecutionStatusFailed || job.Error != executionShutdownError || job.Result != nil || job.Submission != nil { + t.Fatalf("shutdown submit was exposed as a candidate failure: %+v", job) + } +} + +func TestExecutionAdmissionCapsActiveJobsPerUser(t *testing.T) { + repository := store.NewMemoryStore() + addExecutionProblem(repository, "user-cap-test", nil, nil) + cfg := executionConfig() + cfg.ExecutionQueueCapacity = 10 + cfg.ExecutionWorkerCount = 1 + started := make(chan struct{}, 1) + release := make(chan struct{}) + service := NewExecutionServiceWithRunner(repository, cfg, runnerFunc(func(ctx context.Context, _ ProgramRunRequest) models.ExecutionResult { + select { + case started <- struct{}{}: + default: + } + select { + case <-release: + return models.ExecutionResult{Success: true, Status: "SUCCESS"} + case <-ctx.Done(): + return models.ExecutionResult{Status: "RUNTIME_ERROR", Stderr: ctx.Err().Error()} + } + })) + defer service.Close() + + userID := "one-user" + if _, err := service.QueueExecution(executionRequest("user-cap-test"), models.ExecutionKindRun, userID); err != nil { + t.Fatal(err) + } + select { + case <-started: + case <-time.After(time.Second): + t.Fatal("worker did not start") + } + for index := 1; index < maxActiveExecutionsPerUser; index++ { + if _, err := service.QueueExecution(executionRequest("user-cap-test"), models.ExecutionKindRun, userID); err != nil { + t.Fatalf("queue active job %d: %v", index+1, err) + } + } + if _, err := service.QueueExecution(executionRequest("user-cap-test"), models.ExecutionKindRun, userID); !errors.Is(err, ErrExecutionUserSaturated) { + t.Fatalf("per-user saturation error=%v, want ErrExecutionUserSaturated", err) + } + close(release) +} + +func TestTerminalExecutionRetentionIsBounded(t *testing.T) { + repository := store.NewMemoryStore() + service := NewExecutionServiceWithRunner(repository, executionConfig(), runnerFunc(func(context.Context, ProgramRunRequest) models.ExecutionResult { + return models.ExecutionResult{Success: true, Status: "SUCCESS"} + })) + defer service.Close() + + now := time.Now().UTC() + service.mu.Lock() + for index := 0; index < maxRetainedExecutionJobs+25; index++ { + id := fmt.Sprintf("terminal-%04d", index) + service.jobs[id] = models.ExecutionJob{ + ID: id, + Status: models.ExecutionStatusCompleted, + CreatedAt: now.Add(-time.Hour), + UpdatedAt: now.Add(-time.Duration(index) * time.Millisecond), + } + } + service.jobs["expired"] = models.ExecutionJob{ID: "expired", Status: models.ExecutionStatusFailed, UpdatedAt: now.Add(-executionJobRetentionTTL - time.Second)} + service.jobs["active"] = models.ExecutionJob{ID: "active", Status: models.ExecutionStatusRunning, UpdatedAt: now.Add(-time.Hour)} + service.pruneTerminalJobsLocked(now) + jobCount := len(service.jobs) + _, activeRetained := service.jobs["active"] + _, expiredRetained := service.jobs["expired"] + service.mu.Unlock() + + if jobCount > maxRetainedExecutionJobs { + t.Fatalf("retained %d jobs, max=%d", jobCount, maxRetainedExecutionJobs) + } + if !activeRetained { + t.Fatal("retention pruning removed an active job") + } + if expiredRetained { + t.Fatal("retention pruning kept an expired terminal job") + } +} + +func TestExecutionSelectionUsesServerCatalogAndRunnerMetadata(t *testing.T) { + repository := store.NewMemoryStore() + addExecutionProblem(repository, "selection-test", []models.TestCase{ + { + ID: "case-one", + Name: "Case one", + Visibility: models.TestPublic, + APIMethod: "firstMethod", + Input: "first-input", + ExpectedOutput: "first-input", + ConcurrentWorkers: 1, + }, + { + ID: "case-two", + Name: "Case two", + Visibility: models.TestPublic, + APIMethod: "secondMethod", + Input: "second-input", + ExpectedOutput: "second-input", + ConcurrentWorkers: 3, + }, + }, nil) + + calls := make(chan ProgramRunRequest, 16) + service := NewExecutionServiceWithRunner(repository, executionConfig(), runnerFunc(func(_ context.Context, run ProgramRunRequest) models.ExecutionResult { + calls <- run + return models.ExecutionResult{Success: true, Status: "SUCCESS", Stdout: run.Request.CustomInput} + })) + defer service.Close() + + plainRequest := executionRequest("selection-test") + plainRequest.CustomInput = "custom-stdin" + plain, err := service.QueueExecution(plainRequest, models.ExecutionKindRun, "usr_cand_01") + if err != nil { + t.Fatal(err) + } + plainJob := waitForTerminalJob(t, service, plain.ID) + if plainJob.Result == nil || plainJob.Result.Stdout != "custom-stdin" || plainJob.Result.TotalTests != 0 { + t.Fatalf("plain run did not preserve custom stdin semantics: %+v", plainJob.Result) + } + + selectedRequest := executionRequest("selection-test") + selectedRequest.TestCaseID = "case-one" + selected, err := service.QueueExecution(selectedRequest, models.ExecutionKindRun, "usr_cand_01") + if err != nil { + t.Fatal(err) + } + selectedJob := waitForTerminalJob(t, service, selected.ID) + if selectedJob.Result == nil || selectedJob.Result.TotalTests != 1 || selectedJob.Result.PassedTests != 1 || selectedJob.Result.TestResults[0].TestCaseID != "case-one" { + t.Fatalf("selected run result=%+v", selectedJob.Result) + } + + suiteRequest := executionRequest("selection-test") + suiteRequest.RunTests = true + suite, err := service.QueueExecution(suiteRequest, models.ExecutionKindRun, "usr_cand_01") + if err != nil { + t.Fatal(err) + } + suiteJob := waitForTerminalJob(t, service, suite.ID) + if suiteJob.Result == nil || suiteJob.Result.TotalTests != 2 || suiteJob.Result.PassedTests != 2 { + t.Fatalf("public suite result=%+v", suiteJob.Result) + } + + close(calls) + seenPlain := false + seenFirst := 0 + seenSecondWorkers := make(map[int]bool) + for call := range calls { + switch call.Request.CustomInput { + case "custom-stdin": + seenPlain = call.APIMethod == "" && call.ConcurrentWorkers == 1 + case "first-input": + if call.APIMethod == "firstMethod" && call.ConcurrentWorkers == 1 { + seenFirst++ + } + case "second-input": + if call.APIMethod == "secondMethod" && call.ConcurrentWorkers == 3 { + seenSecondWorkers[call.WorkerIndex] = true + } + } + } + if !seenPlain || seenFirst != 2 || len(seenSecondWorkers) != 3 { + t.Fatalf("runner metadata not propagated: plain=%v firstCalls=%d secondWorkers=%v", seenPlain, seenFirst, seenSecondWorkers) + } +} + +func TestSubmitCompletionPersistsAndRedactsSubmission(t *testing.T) { + repository := store.NewMemoryStore() + addExecutionProblem(repository, "submission-test", + []models.TestCase{{ID: "public", Name: "Public", Visibility: models.TestPublic, Input: "public-ok", ExpectedOutput: "public-ok"}}, + []models.TestCase{{ID: "hidden", Name: "Hidden", Visibility: models.TestHidden, Input: "hidden-secret", ExpectedOutput: "hidden-secret"}}, + ) + service := NewExecutionServiceWithRunner(repository, executionConfig(), runnerFunc(func(_ context.Context, run ProgramRunRequest) models.ExecutionResult { + return models.ExecutionResult{Success: true, Status: "SUCCESS", Stdout: run.Request.CustomInput, DurationMs: 7} + })) + defer service.Close() + submissions := NewSubmissionService(repository) + service.SetSubmissionRecorder(submissions) + + queued, err := service.QueueExecution(executionRequest("submission-test"), models.ExecutionKindSubmit, "usr_cand_01") + if err != nil { + t.Fatalf("queue submit: %v", err) + } + job := waitForTerminalJob(t, service, queued.ID) + if job.Status != models.ExecutionStatusCompleted { + t.Fatalf("job status=%s, want COMPLETED", job.Status) + } + if job.Submission == nil { + t.Fatal("completed submit job did not expose a submission") + } + if job.Submission.Status != models.SubmissionStatusAccepted { + t.Fatalf("submission status=%s, want ACCEPTED", job.Submission.Status) + } + if len(job.Result.TestResults) != 2 || job.Result.TestResults[1].Expected != "" || job.Result.TestResults[1].Actual != "" || job.Result.TestResults[1].TestCaseID == "hidden" || job.Result.TestResults[1].Name == "Hidden" { + t.Fatalf("hidden expected value leaked through execution result: %+v", job.Result.TestResults) + } + if strings.Contains(job.Result.Stdout, "hidden-secret") || strings.Contains(job.Result.Stderr, "hidden-secret") { + t.Fatalf("hidden output leaked through aggregate execution streams: %+v", job.Result) + } + if len(job.Submission.TestResults) != 2 || job.Submission.TestResults[1].Expected != "" || job.Submission.TestResults[1].Actual != "" || job.Submission.TestResults[1].TestCaseID == "hidden" || job.Submission.TestResults[1].Name == "Hidden" { + t.Fatalf("hidden expected value leaked through submission: %+v", job.Submission.TestResults) + } + persisted, err := submissions.GetUserSubmissions("usr_cand_01", "submission-test") + if err != nil { + t.Fatalf("list persisted submissions: %v", err) + } + if len(persisted) != 1 || persisted[0].ID != job.Submission.ID { + t.Fatalf("persisted submissions=%+v, want completed job submission", persisted) + } +} + +func TestQueuedSubmitUsesImmutableEvaluationSnapshot(t *testing.T) { + repository := store.NewMemoryStore() + addExecutionProblem(repository, "snapshot-test", []models.TestCase{{ + ID: "public", + Name: "Original", + Visibility: models.TestPublic, + Input: "original-output", + ExpectedOutput: "original-output", + }}, nil) + original, found := repository.GetProblemByID("snapshot-test") + if !found { + t.Fatal("snapshot problem was not stored") + } + wantRevision := evaluationRevision(original) + + started := make(chan ProgramRunRequest, 1) + release := make(chan struct{}) + cfg := executionConfig() + cfg.ExecutionWorkerCount = 1 + service := NewExecutionServiceWithRunner(repository, cfg, runnerFunc(func(_ context.Context, run ProgramRunRequest) models.ExecutionResult { + started <- run + <-release + return models.ExecutionResult{Success: true, Status: "SUCCESS", Stdout: run.Request.CustomInput} + })) + defer service.Close() + service.SetSubmissionRecorder(NewSubmissionService(repository)) + + queued, err := service.QueueExecution(executionRequest("snapshot-test"), models.ExecutionKindSubmit, "usr_cand_01") + if err != nil { + t.Fatalf("queue submit: %v", err) + } + if queued.EvaluationRevision != wantRevision { + t.Fatalf("queue captured revision=%q, want %q", queued.EvaluationRevision, wantRevision) + } + firstRun := <-started + if firstRun.Request.CustomInput != "original-output" { + t.Fatalf("queued test input=%q, want original snapshot", firstRun.Request.CustomInput) + } + + original.TestCases[0].Name = "Replacement" + original.TestCases[0].Input = "replacement-output" + original.TestCases[0].ExpectedOutput = "replacement-output" + if _, err := repository.SaveProblem(original); err != nil { + t.Fatalf("replace live catalog problem: %v", err) + } + close(release) + + job := waitForTerminalJob(t, service, queued.ID) + if job.Submission == nil || job.Result == nil { + t.Fatalf("terminal job lacks result/submission: %+v", job) + } + if job.Submission.EvaluationRevision != wantRevision { + t.Fatalf("evaluation revision=%q, want %q", job.Submission.EvaluationRevision, wantRevision) + } + if len(job.Result.TestResults) != 1 || job.Result.TestResults[0].Name != "Original" || !job.Result.TestResults[0].Passed { + t.Fatalf("execution did not retain original evaluation snapshot: %+v", job.Result.TestResults) + } +} + +func TestFailedSubmitStillPersistsAttempt(t *testing.T) { + repository := store.NewMemoryStore() + addExecutionProblem(repository, "failed-submission", + []models.TestCase{{ID: "public", Name: "Public", Visibility: models.TestPublic, ExpectedOutput: "ok"}}, nil, + ) + service := NewExecutionServiceWithRunner(repository, executionConfig(), runnerFunc(func(context.Context, ProgramRunRequest) models.ExecutionResult { + return models.ExecutionResult{Status: "COMPILE_ERROR", Stderr: "compile failed"} + })) + defer service.Close() + submissions := NewSubmissionService(repository) + service.SetSubmissionRecorder(submissions) + + queued, err := service.QueueExecution(executionRequest("failed-submission"), models.ExecutionKindSubmit, "usr_cand_01") + if err != nil { + t.Fatal(err) + } + job := waitForTerminalJob(t, service, queued.ID) + if job.Status != models.ExecutionStatusFailed || job.Submission == nil { + t.Fatalf("failed terminal job did not retain submission: %+v", job) + } + if job.Submission.Status != models.SubmissionStatusCompileError { + t.Fatalf("submission status=%s, want COMPILE_ERROR", job.Submission.Status) + } +} + +func TestEvaluateTestCasesDoesNotAutoPassSuccessfulProcess(t *testing.T) { + if outputMatches("different\nexpected\nanother", "expected") { + t.Fatal("one matching line incorrectly satisfied an exact-output assertion") + } +} + +func TestCappedBufferNeverRetainsMoreThanLimit(t *testing.T) { + buffer := newCappedBuffer(4) + written, err := buffer.Write([]byte("123456789")) + if err != nil || written != 9 { + t.Fatalf("Write()=(%d,%v), want (9,nil)", written, err) + } + if got := buffer.String(); got != "1234" { + t.Fatalf("captured output=%q, want %q", got, "1234") + } + if !buffer.truncated { + t.Fatal("buffer did not record truncation") + } +} + +func TestSubmissionStatusPreservesEvaluationUnavailable(t *testing.T) { + if status := submissionStatus("EVALUATION_UNAVAILABLE", 0, 0); status != models.SubmissionStatusEvaluationUnavailable { + t.Fatalf("submission status=%q, want %q", status, models.SubmissionStatusEvaluationUnavailable) + } +} diff --git a/interviewhub-backend-go/internal/services/execution_validation.go b/interviewhub-backend-go/internal/services/execution_validation.go new file mode 100644 index 00000000..da4afbb1 --- /dev/null +++ b/interviewhub-backend-go/internal/services/execution_validation.go @@ -0,0 +1,165 @@ +package services + +import ( + "errors" + "fmt" + "path/filepath" + "strings" + + "github.com/interviewhub/backend/internal/models" +) + +func (e *ExecutionService) validateExecutionRequest(request models.ExecutionRequest, kind models.ExecutionKind, userID string) (models.Problem, error) { + if !e.cfg.ExecutionEnabled || e.cfg.ExecutionRunner == "disabled" { + return models.Problem{}, ErrExecutionDisabled + } + if e.runner == nil { + return models.Problem{}, errors.New("execution runner is not configured") + } + if kind != models.ExecutionKindRun && kind != models.ExecutionKindSubmit { + return models.Problem{}, errors.New("unsupported execution kind") + } + if strings.TrimSpace(request.ProblemID) == "" { + return models.Problem{}, errors.New("problemId is required") + } + problem, ok, err := lookupProblemByID(e.store, request.ProblemID) + if err != nil { + return models.Problem{}, serviceError(ErrDependencyUnavailable, "problem catalog is unavailable", err) + } + if !ok { + return models.Problem{}, errors.New("problem not found") + } + if !isSupportedLanguage(request.Language) { + return models.Problem{}, errors.New("unsupported language") + } + if kind == models.ExecutionKindSubmit { + if strings.TrimSpace(request.TestCaseID) != "" || request.RunTests { + return models.Problem{}, errors.New("test selection is only supported for run executions") + } + if strings.TrimSpace(userID) == "" { + return models.Problem{}, errors.New("authenticated user is required for submissions") + } + if _, ok, err := lookupUserByID(e.store, userID); err != nil { + return models.Problem{}, serviceError(ErrDependencyUnavailable, "account storage is unavailable", err) + } else if !ok { + return models.Problem{}, errors.New("submission user not found") + } + } else if selectedID := strings.TrimSpace(request.TestCaseID); selectedID != "" { + if request.RunTests { + return models.Problem{}, errors.New("testCaseId and runTests cannot be used together") + } + found := false + for _, testCase := range problem.TestCases { + if testCase.ID == selectedID && testCase.Visibility != models.TestHidden { + found = true + break + } + } + if !found { + return models.Problem{}, errors.New("public test case not found") + } + } + if len(request.Files) == 0 { + return models.Problem{}, errors.New("at least one source file is required") + } + if err := e.validateFileTree(request.Files, request.Language); err != nil { + return models.Problem{}, err + } + return cloneEvaluationProblem(problem), nil +} + +func cloneEvaluationProblem(problem models.Problem) models.Problem { + problem.TestCases = append([]models.TestCase(nil), problem.TestCases...) + problem.HiddenTestCases = append([]models.TestCase(nil), problem.HiddenTestCases...) + return problem +} + +func cloneExecutionRequest(request models.ExecutionRequest) models.ExecutionRequest { + request.Files = cloneFileNodes(request.Files) + request.TestCases = append([]models.TestCase(nil), request.TestCases...) + return request +} + +func cloneFileNodes(nodes []models.FileNode) []models.FileNode { + cloned := append([]models.FileNode(nil), nodes...) + for index := range cloned { + cloned[index].Children = cloneFileNodes(cloned[index].Children) + } + return cloned +} + +func (e *ExecutionService) validateFileTree(nodes []models.FileNode, language models.Language) error { + maxFiles := e.cfg.ExecutionMaxFileCount + if maxFiles < 1 { + maxFiles = 50 + } + seenPaths := make(map[string]struct{}) + fileCount := 0 + hasSource := false + var walk func([]models.FileNode) error + walk = func(items []models.FileNode) error { + for _, node := range items { + fileCount++ + if fileCount > maxFiles { + return fmt.Errorf("execution file count exceeds limit of %d", maxFiles) + } + cleanPath, err := cleanSandboxRelativePath(node.Path) + if err != nil { + return err + } + if _, duplicate := seenPaths[cleanPath]; duplicate { + return fmt.Errorf("duplicate source path %q", node.Path) + } + seenPaths[cleanPath] = struct{}{} + if node.IsDirectory { + if node.Content != "" { + return fmt.Errorf("directory %q cannot contain source content", node.Path) + } + } else { + if len(node.Children) > 0 { + return fmt.Errorf("file %q cannot contain child nodes", node.Path) + } + if sourceExtensionMatches(cleanPath, language) { + hasSource = true + } + } + if err := walk(node.Children); err != nil { + return err + } + } + return nil + } + if err := walk(nodes); err != nil { + return err + } + if !hasSource { + return fmt.Errorf("no %s source files were provided", language) + } + return nil +} + +func sourceExtensionMatches(path string, language models.Language) bool { + extension := strings.ToLower(filepath.Ext(path)) + switch language { + case models.LangJava: + return extension == ".java" + case models.LangPython: + return extension == ".py" + case models.LangGo: + return extension == ".go" + case models.LangCpp: + return extension == ".cpp" || extension == ".cc" || extension == ".cxx" + case models.LangTypeScript: + return extension == ".ts" || extension == ".tsx" + default: + return false + } +} + +func cleanSandboxRelativePath(relativePath string) (string, error) { + clean := filepath.Clean(strings.TrimSpace(relativePath)) + if clean == "." || clean == string(filepath.Separator) || filepath.IsAbs(clean) || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) { + return "", fmt.Errorf("invalid sandbox path %q", relativePath) + } + return clean, nil +} diff --git a/interviewhub-backend-go/internal/services/execution_worker.go b/interviewhub-backend-go/internal/services/execution_worker.go new file mode 100644 index 00000000..811f474d --- /dev/null +++ b/interviewhub-backend-go/internal/services/execution_worker.go @@ -0,0 +1,212 @@ +package services + +import ( + "context" + "sort" + "strings" + "time" + + "github.com/interviewhub/backend/internal/models" +) + +func (e *ExecutionService) worker() { + defer e.workers.Done() + for { + select { + case <-e.ctx.Done(): + return + case id := <-e.queue: + // Cancellation and a buffered queue item can become ready together. + // Recheck the context so shutdown never drains buffered work. + if e.ctx.Err() != nil { + return + } + e.processJob(id) + } + } +} + +func (e *ExecutionService) processJob(id string) { + e.mu.Lock() + job, ok := e.jobs[id] + if !ok || e.closed || job.Status != models.ExecutionStatusQueued { + e.mu.Unlock() + return + } + job.Status = models.ExecutionStatusRunning + job.UpdatedAt = time.Now().UTC() + e.jobs[id] = job + e.mu.Unlock() + + result := e.executeInternal(job.Request, job.Kind, job.EvaluationProblem) + + // Completion and shutdown are linearized under the service mutex. If + // shutdown won, the result came from cancellation of service-owned work and + // must not be represented or persisted as a candidate runtime failure. + e.mu.Lock() + current, exists := e.jobs[id] + if !exists { + e.mu.Unlock() + return + } + if e.closed { + markExecutionStoppedByShutdown(¤t, time.Now().UTC()) + e.jobs[id] = current + e.mu.Unlock() + return + } + e.mu.Unlock() + + var submission *models.Submission + var persistenceError error + if job.Kind == models.ExecutionKindSubmit { + recorder := e.submissionRecorder() + if recorder == nil { + persistenceError = ErrSubmissionNotConfigured + } else { + recorded, err := recorder.RecordCompletedSubmission(job.UserID, job.Request, job.EvaluationProblem, job.EvaluationRevision, result) + if err != nil { + persistenceError = err + } else { + submission = &recorded + } + } + } + + e.mu.Lock() + current, exists = e.jobs[id] + if !exists { + e.mu.Unlock() + return + } + current.Result = &result + current.Submission = submission + current.UpdatedAt = time.Now().UTC() + if persistenceError != nil { + current.Status = models.ExecutionStatusFailed + current.Error = "failed to persist completed submission" + } else { + current.Status = terminalJobStatus(result.Status) + } + e.jobs[id] = current + e.pruneTerminalJobsLocked(current.UpdatedAt) + e.mu.Unlock() +} + +func markExecutionStoppedByShutdown(job *models.ExecutionJob, now time.Time) { + job.Status = models.ExecutionStatusFailed + job.Result = nil + job.Submission = nil + job.Error = executionShutdownError + job.UpdatedAt = now +} + +func (e *ExecutionService) activeExecutionsForUserLocked(userID string) int { + count := 0 + for _, job := range e.jobs { + if job.UserID == userID && (job.Status == models.ExecutionStatusQueued || job.Status == models.ExecutionStatusRunning) { + count++ + } + } + return count +} + +func (e *ExecutionService) pruneTerminalJobsLocked(now time.Time) { + type terminalJob struct { + id string + updatedAt time.Time + } + terminal := make([]terminalJob, 0) + for id, job := range e.jobs { + if !isTerminalExecutionStatus(job.Status) { + continue + } + if now.Sub(job.UpdatedAt) > executionJobRetentionTTL { + delete(e.jobs, id) + continue + } + terminal = append(terminal, terminalJob{id: id, updatedAt: job.UpdatedAt}) + } + if len(e.jobs) <= maxRetainedExecutionJobs { + return + } + sort.Slice(terminal, func(i, j int) bool { + if terminal[i].updatedAt.Equal(terminal[j].updatedAt) { + return terminal[i].id < terminal[j].id + } + return terminal[i].updatedAt.Before(terminal[j].updatedAt) + }) + for _, candidate := range terminal { + if len(e.jobs) <= maxRetainedExecutionJobs { + break + } + delete(e.jobs, candidate.id) + } +} + +func isTerminalExecutionStatus(status models.ExecutionStatus) bool { + switch status { + case models.ExecutionStatusCompleted, models.ExecutionStatusFailed, models.ExecutionStatusTimedOut: + return true + default: + return false + } +} + +func (e *ExecutionService) submissionRecorder() SubmissionRecorder { + e.recorderMu.RLock() + defer e.recorderMu.RUnlock() + return e.recorder +} + +func terminalJobStatus(resultStatus string) models.ExecutionStatus { + switch resultStatus { + case "SUCCESS": + return models.ExecutionStatusCompleted + case "TIMEOUT": + return models.ExecutionStatusTimedOut + default: + return models.ExecutionStatusFailed + } +} + +func (e *ExecutionService) executeInternal(request models.ExecutionRequest, kind models.ExecutionKind, problem models.Problem) (result models.ExecutionResult) { + start := time.Now() + defer func() { + if recovered := recover(); recovered != nil { + result = models.ExecutionResult{ + Status: "RUNTIME_ERROR", + Stderr: "execution worker failed unexpectedly", + DurationMs: time.Since(start).Milliseconds(), + } + } + }() + + if kind == models.ExecutionKindRun && strings.TrimSpace(request.TestCaseID) == "" && !request.RunTests { + ctx, cancel := context.WithTimeout(e.ctx, e.directRunTimeout(request.Language)) + defer cancel() + return e.runProgram(ctx, ProgramRunRequest{ + Request: request, + WorkerIndex: 0, + ConcurrentWorkers: 1, + }) + } + return e.executeTestSuite(problem, request, kind) +} + +func cloneExecutionJob(job models.ExecutionJob) models.ExecutionJob { + job.Request = cloneExecutionRequest(job.Request) + job.EvaluationProblem = cloneEvaluationProblem(job.EvaluationProblem) + if job.Result != nil { + result := *job.Result + result.TestResults = append([]models.TestResult(nil), job.Result.TestResults...) + job.Result = &result + } + if job.Submission != nil { + submission := *job.Submission + submission.TestResults = append([]models.TestResult(nil), job.Submission.TestResults...) + submission.ConsoleOutput = append([]string(nil), job.Submission.ConsoleOutput...) + job.Submission = &submission + } + return job +} diff --git a/interviewhub-backend-go/internal/services/local_program_runner.go b/interviewhub-backend-go/internal/services/local_program_runner.go new file mode 100644 index 00000000..9e47dc23 --- /dev/null +++ b/interviewhub-backend-go/internal/services/local_program_runner.go @@ -0,0 +1,341 @@ +package services + +import ( + "bytes" + "context" + "errors" + "io/fs" + "os" + "os/exec" + "path/filepath" + "sort" + "strconv" + "strings" + "time" + + "github.com/interviewhub/backend/internal/config" + "github.com/interviewhub/backend/internal/models" +) + +type localProgramRunner struct { + cfg *config.Config +} + +func (r *localProgramRunner) Run(ctx context.Context, runRequest ProgramRunRequest) models.ExecutionResult { + started := time.Now() + request := runRequest.Request + baseDir := r.cfg.SandboxTempDir + if strings.TrimSpace(baseDir) == "" { + baseDir = filepath.Join(os.TempDir(), "judgemycode-sandboxes") + } + if err := os.MkdirAll(baseDir, 0700); err != nil { + return failedExecution("RUNTIME_ERROR", "failed to initialize sandbox storage", started) + } + sandboxDir, err := os.MkdirTemp(baseDir, "execution-") + if err != nil { + return failedExecution("RUNTIME_ERROR", "failed to initialize sandbox directory", started) + } + defer func() { _ = os.RemoveAll(sandboxDir) }() + + if err := writeFilesRecursively(sandboxDir, request.Files); err != nil { + return failedExecution("RUNTIME_ERROR", "failed to stage source files: "+err.Error(), started) + } + + limit := r.cfg.ExecutionMaxOutputBytes + if limit < 1 { + limit = 1 << 20 + } + stdout := newCappedBuffer(limit) + stderr := newCappedBuffer(limit) + runCommand := func(name string, args []string, stdin string) error { + command := exec.CommandContext(ctx, name, args...) + command.Dir = sandboxDir + command.Env = localExecutionEnvironment(sandboxDir, runRequest) + command.Stdout = stdout + command.Stderr = stderr + if stdin != "" { + command.Stdin = strings.NewReader(stdin) + } + return command.Run() + } + resultFor := func(failureStatus string, commandErr error) models.ExecutionResult { + status := "SUCCESS" + stderrText := strings.TrimSpace(stderr.String()) + switch { + case stdout.truncated || stderr.truncated: + status = "OUTPUT_LIMIT_EXCEEDED" + if stderrText == "" { + stderrText = "process output exceeded the configured limit" + } + case errors.Is(ctx.Err(), context.DeadlineExceeded): + status = "TIMEOUT" + stderrText = "time limit exceeded" + case commandErr != nil && runtimeIsUnavailable(commandErr): + status = "RUNTIME_UNAVAILABLE" + case commandErr != nil: + status = failureStatus + } + if commandErr != nil && stderrText == "" { + stderrText = commandErr.Error() + } + + return models.ExecutionResult{ + Success: status == "SUCCESS", + Status: status, + Stdout: strings.TrimSpace(stdout.String()), + Stderr: stderrText, + DurationMs: time.Since(started).Milliseconds(), + } + } + runPhase := func(failureStatus, name string, args []string, stdin string) *models.ExecutionResult { + commandErr := runCommand(name, args, stdin) + if commandErr == nil && !stdout.truncated && !stderr.truncated { + return nil + } + result := resultFor(failureStatus, commandErr) + return &result + } + prepareOutputDirectory := func() *models.ExecutionResult { + if mkdirErr := os.MkdirAll(filepath.Join(sandboxDir, "out"), 0700); mkdirErr != nil { + result := failedExecution("RUNTIME_ERROR", "failed to initialize build output: "+mkdirErr.Error(), started) + return &result + } + return nil + } + + switch request.Language { + case models.LangPython: + entrypoint, findErr := findEntrypoint(sandboxDir, "src/main.py", ".py") + if findErr != nil { + return failedExecution("COMPILE_ERROR", findErr.Error(), started) + } + files, findErr := sourceFiles(sandboxDir, ".py") + if findErr != nil { + return failedExecution("COMPILE_ERROR", findErr.Error(), started) + } + if failure := runPhase("COMPILE_ERROR", "python3", append([]string{"-m", "py_compile"}, files...), ""); failure != nil { + return *failure + } + if failure := runPhase("RUNTIME_ERROR", "python3", []string{entrypoint}, request.CustomInput); failure != nil { + return *failure + } + case models.LangJava: + files, findErr := sourceFiles(sandboxDir, ".java") + if findErr != nil { + return failedExecution("COMPILE_ERROR", findErr.Error(), started) + } + if failure := prepareOutputDirectory(); failure != nil { + return *failure + } + if failure := runPhase("COMPILE_ERROR", "javac", append([]string{"-d", "out"}, files...), ""); failure != nil { + return *failure + } + if failure := runPhase("RUNTIME_ERROR", "java", []string{"-cp", "out", "Main"}, request.CustomInput); failure != nil { + return *failure + } + case models.LangTypeScript: + entrypoint, findErr := findTypeScriptEntrypoint(sandboxDir) + if findErr != nil { + return failedExecution("COMPILE_ERROR", findErr.Error(), started) + } + files, findErr := sourceFiles(sandboxDir, ".ts", ".tsx") + if findErr != nil { + return failedExecution("COMPILE_ERROR", findErr.Error(), started) + } + // Node >=22.12 can transform TypeScript-only syntax such as enums without + // installing dependencies in the candidate sandbox. Check every source + // first so syntax/transform failures remain distinct from runtime errors. + for _, file := range files { + if failure := runPhase("COMPILE_ERROR", "node", []string{"--experimental-transform-types", "--check", file}, ""); failure != nil { + return *failure + } + } + if failure := runPhase("RUNTIME_ERROR", "node", []string{"--experimental-transform-types", entrypoint}, request.CustomInput); failure != nil { + return *failure + } + case models.LangCpp: + files, findErr := sourceFiles(sandboxDir, ".cpp", ".cc", ".cxx") + if findErr != nil { + return failedExecution("COMPILE_ERROR", findErr.Error(), started) + } + if failure := prepareOutputDirectory(); failure != nil { + return *failure + } + if failure := runPhase("COMPILE_ERROR", "g++", append([]string{"-std=c++17", "-o", "out/app"}, files...), ""); failure != nil { + return *failure + } + if failure := runPhase("RUNTIME_ERROR", filepath.Join(sandboxDir, "out", "app"), nil, request.CustomInput); failure != nil { + return *failure + } + case models.LangGo: + files, findErr := sourceFiles(sandboxDir, ".go") + if findErr != nil { + return failedExecution("COMPILE_ERROR", findErr.Error(), started) + } + if failure := prepareOutputDirectory(); failure != nil { + return *failure + } + if failure := runPhase("COMPILE_ERROR", "go", append([]string{"build", "-o", "out/app"}, files...), ""); failure != nil { + return *failure + } + if failure := runPhase("RUNTIME_ERROR", filepath.Join(sandboxDir, "out", "app"), nil, request.CustomInput); failure != nil { + return *failure + } + default: + return failedExecution("COMPILE_ERROR", "unsupported language", started) + } + + return resultFor("RUNTIME_ERROR", nil) +} + +func failedExecution(status, message string, started time.Time) models.ExecutionResult { + return models.ExecutionResult{Status: status, Stderr: message, DurationMs: time.Since(started).Milliseconds()} +} + +func writeFilesRecursively(baseDir string, nodes []models.FileNode) error { + for _, node := range nodes { + targetPath, err := safeSandboxPath(baseDir, node.Path) + if err != nil { + return err + } + if node.IsDirectory { + if err := os.MkdirAll(targetPath, 0700); err != nil { + return err + } + } else { + if err := os.MkdirAll(filepath.Dir(targetPath), 0700); err != nil { + return err + } + if err := os.WriteFile(targetPath, []byte(node.Content), 0600); err != nil { + return err + } + } + // FileNode paths are root-relative even when represented as a tree. + if err := writeFilesRecursively(baseDir, node.Children); err != nil { + return err + } + } + return nil +} + +func safeSandboxPath(baseDir, relativePath string) (string, error) { + clean, err := cleanSandboxRelativePath(relativePath) + if err != nil { + return "", err + } + return filepath.Join(baseDir, clean), nil +} + +func sourceFiles(root string, extensions ...string) ([]string, error) { + allowed := make(map[string]struct{}, len(extensions)) + for _, extension := range extensions { + allowed[extension] = struct{}{} + } + files := make([]string, 0) + err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if entry.IsDir() { + return nil + } + if _, ok := allowed[strings.ToLower(filepath.Ext(path))]; ok { + relative, err := filepath.Rel(root, path) + if err != nil { + return err + } + files = append(files, relative) + } + return nil + }) + if err != nil { + return nil, err + } + if len(files) == 0 { + return nil, errors.New("no source files found") + } + sort.Strings(files) + return files, nil +} + +func findEntrypoint(root, preferred, extension string) (string, error) { + if info, err := os.Stat(filepath.Join(root, preferred)); err == nil && !info.IsDir() { + return preferred, nil + } + files, err := sourceFiles(root, extension) + if err != nil { + return "", err + } + return files[0], nil +} + +func findTypeScriptEntrypoint(root string) (string, error) { + for _, preferred := range []string{"src/Main.ts", "src/main.ts", "src/Main.tsx", "src/main.tsx"} { + if info, err := os.Stat(filepath.Join(root, preferred)); err == nil && !info.IsDir() { + return preferred, nil + } + } + files, err := sourceFiles(root, ".ts", ".tsx") + if err != nil { + return "", err + } + return files[0], nil +} + +func localExecutionEnvironment(sandboxDir string, runRequest ProgramRunRequest) []string { + values := []string{ + "HOME=" + sandboxDir, + "TMPDIR=" + sandboxDir, + "GOCACHE=" + filepath.Join(sandboxDir, ".cache", "go-build"), + "GOPROXY=off", + "GOTOOLCHAIN=local", + "JUDGEMYCODE_API_METHOD=" + runRequest.APIMethod, + "JUDGEMYCODE_WORKER_INDEX=" + strconv.Itoa(runRequest.WorkerIndex), + "JUDGEMYCODE_CONCURRENT_WORKERS=" + strconv.Itoa(runRequest.ConcurrentWorkers), + } + for _, key := range []string{"PATH", "LANG", "LC_ALL", "GOROOT"} { + if value := os.Getenv(key); value != "" { + values = append(values, key+"="+value) + } + } + return values +} + +func runtimeIsUnavailable(err error) bool { + var execError *exec.Error + return errors.As(err, &execError) +} + +type cappedBuffer struct { + buffer bytes.Buffer + limit int64 + written int64 + truncated bool +} + +func newCappedBuffer(limit int64) *cappedBuffer { + return &cappedBuffer{limit: limit} +} + +func (b *cappedBuffer) Write(payload []byte) (int, error) { + originalLength := len(payload) + remaining := b.limit - b.written + if remaining <= 0 { + b.truncated = b.truncated || originalLength > 0 + return originalLength, nil + } + if int64(len(payload)) > remaining { + payload = payload[:remaining] + b.truncated = true + } + written, err := b.buffer.Write(payload) + b.written += int64(written) + if err != nil { + return written, err + } + // Report the original length so os/exec does not treat intentional capture + // truncation as a short write from the candidate process. + return originalLength, nil +} + +func (b *cappedBuffer) String() string { return b.buffer.String() } diff --git a/interviewhub-backend-go/internal/services/local_program_runner_test.go b/interviewhub-backend-go/internal/services/local_program_runner_test.go new file mode 100644 index 00000000..561137f2 --- /dev/null +++ b/interviewhub-backend-go/internal/services/local_program_runner_test.go @@ -0,0 +1,136 @@ +package services + +import ( + "context" + "os/exec" + "strconv" + "strings" + "testing" + "time" + + "github.com/interviewhub/backend/internal/config" + "github.com/interviewhub/backend/internal/data" + "github.com/interviewhub/backend/internal/models" +) + +func TestLocalProgramRunnerClassifiesPythonValueErrorAsRuntimeError(t *testing.T) { + requireRunnerTool(t, "python3") + runner := newTestLocalProgramRunner(t) + + result := runLocalProgram(t, runner, models.ExecutionRequest{ + Language: models.LangPython, + Files: []models.FileNode{{ + Name: "main.py", + Path: "src/main.py", + Language: models.LangPython, + Content: "raise ValueError(\"Error: candidate runtime failure\")\n", + }}, + }) + + if result.Status != "RUNTIME_ERROR" { + t.Fatalf("status = %q, want RUNTIME_ERROR; stderr=%q", result.Status, result.Stderr) + } + if !strings.Contains(result.Stderr, "ValueError: Error: candidate runtime failure") { + t.Fatalf("stderr = %q, want Python ValueError", result.Stderr) + } +} + +func TestLocalProgramRunnerClassifiesGoBuildFailureAsCompileError(t *testing.T) { + requireRunnerTool(t, "go") + runner := newTestLocalProgramRunner(t) + + result := runLocalProgram(t, runner, models.ExecutionRequest{ + Language: models.LangGo, + Files: []models.FileNode{{ + Name: "main.go", + Path: "src/main.go", + Language: models.LangGo, + Content: "package main\n\nfunc main() { _ = undefinedSymbol }\n", + }}, + }) + + if result.Status != "COMPILE_ERROR" { + t.Fatalf("status = %q, want COMPILE_ERROR; stderr=%q", result.Status, result.Stderr) + } + if !strings.Contains(result.Stderr, "undefinedSymbol") { + t.Fatalf("stderr = %q, want undefined symbol diagnostic", result.Stderr) + } +} + +func TestLocalProgramRunnerExecutesTypeScriptStarter(t *testing.T) { + requireNodeTransformTypes(t) + runner := newTestLocalProgramRunner(t) + + var starterFiles []models.FileNode + for _, problem := range data.GetSeedProblems() { + if problem.ID == "parking-lot" { + starterFiles = problem.StarterFiles[models.LangTypeScript] + break + } + } + if len(starterFiles) == 0 { + t.Fatal("parking-lot TypeScript starter is missing") + } + + result := runLocalProgram(t, runner, models.ExecutionRequest{ + ProblemID: "parking-lot", + Language: models.LangTypeScript, + Files: starterFiles, + }) + + if result.Status != "SUCCESS" { + t.Fatalf("status = %q, want SUCCESS; stderr=%q", result.Status, result.Stderr) + } + if result.Stdout != "Parking lot starter ready: 35 spots" { + t.Fatalf("stdout = %q, want parking-lot starter output", result.Stdout) + } +} + +func newTestLocalProgramRunner(t *testing.T) *localProgramRunner { + t.Helper() + return &localProgramRunner{cfg: &config.Config{ + SandboxTempDir: t.TempDir(), + ExecutionMaxOutputBytes: 1 << 20, + }} +} + +func runLocalProgram(t *testing.T, runner *localProgramRunner, request models.ExecutionRequest) models.ExecutionResult { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + result := runner.Run(ctx, ProgramRunRequest{Request: request}) + if ctx.Err() != nil { + t.Fatalf("local runner exceeded test deadline: %v", ctx.Err()) + } + return result +} + +func requireRunnerTool(t *testing.T, name string) string { + t.Helper() + binary, err := exec.LookPath(name) + if err != nil { + t.Skipf("candidate runtime %q is not installed", name) + } + return binary +} + +func requireNodeTransformTypes(t *testing.T) { + t.Helper() + node := requireRunnerTool(t, "node") + output, err := exec.Command(node, "--version").Output() + if err != nil { + t.Skipf("cannot inspect Node.js version: %v", err) + } + parts := strings.Split(strings.TrimPrefix(strings.TrimSpace(string(output)), "v"), ".") + if len(parts) < 2 { + t.Skipf("cannot parse Node.js version %q", strings.TrimSpace(string(output))) + } + major, majorErr := strconv.Atoi(parts[0]) + minor, minorErr := strconv.Atoi(parts[1]) + if majorErr != nil || minorErr != nil { + t.Skipf("cannot parse Node.js version %q", strings.TrimSpace(string(output))) + } + if major < 22 || (major == 22 && minor < 12) { + t.Skipf("Node.js >=22.12 is required for native TypeScript execution; found %s", strings.TrimSpace(string(output))) + } +} diff --git a/interviewhub-backend-go/internal/services/oauth_service.go b/interviewhub-backend-go/internal/services/oauth_service.go index 3dc2724b..de943c1d 100644 --- a/interviewhub-backend-go/internal/services/oauth_service.go +++ b/interviewhub-backend-go/internal/services/oauth_service.go @@ -14,7 +14,9 @@ import ( "net/url" "strconv" "strings" + "sync" "time" + "unicode/utf8" "github.com/google/uuid" "github.com/interviewhub/backend/internal/config" @@ -25,32 +27,43 @@ import ( type OAuthProvider string const ( - ProviderGoogle OAuthProvider = "google" - ProviderGitHub OAuthProvider = "github" - ProviderLinkedIn OAuthProvider = "linkedin" + ProviderGoogle OAuthProvider = "google" + ProviderGitHub OAuthProvider = "github" + ProviderLinkedIn OAuthProvider = "linkedin" + oauthExchangeConcurrency = 8 + maxOAuthProviderResponseBytes = 256 << 10 + maxOAuthEmailBytes = 254 + maxOAuthNameBytes = 120 + maxOAuthAvatarBytes = 2048 ) type OAuthUserProfile struct { - ID string - Email string - Name string - Avatar string - Provider string - RawClaims map[string]any + ID string + Email string + EmailVerified bool + Name string + Avatar string + Provider string + RawClaims map[string]any } type OAuthService struct { - store store.Repository - authService *AuthService - cfg *config.Config - httpClient *http.Client + store store.Repository + authService *AuthService + cfg *config.Config + httpClient *http.Client + stateMu sync.Mutex + usedStates map[string]time.Time + exchangeSlots chan struct{} } func NewOAuthService(s store.Repository, auth *AuthService, cfg *config.Config) *OAuthService { return &OAuthService{ - store: s, - authService: auth, - cfg: cfg, + store: s, + authService: auth, + cfg: cfg, + usedStates: make(map[string]time.Time), + exchangeSlots: make(chan struct{}, oauthExchangeConcurrency), httpClient: &http.Client{ Timeout: 10 * time.Second, }, @@ -121,6 +134,19 @@ func (s *OAuthService) ValidateState(provider, rawEncodedState string) bool { return false } + // State is a one-time credential. A valid signature alone is insufficient: + // replaying a previously accepted callback must also be rejected. + s.stateMu.Lock() + defer s.stateMu.Unlock() + for state, acceptedAt := range s.usedStates { + if now.Sub(acceptedAt) > 10*time.Minute { + delete(s.usedStates, state) + } + } + if _, used := s.usedStates[rawEncodedState]; used { + return false + } + s.usedStates[rawEncodedState] = now return true } @@ -192,6 +218,14 @@ func (s *OAuthService) ResolveOAuthUser(ctx context.Context, provider, code, sta if strings.TrimSpace(code) == "" { return models.User{}, errors.New("missing OAuth authorization code") } + select { + case s.exchangeSlots <- struct{}{}: + defer func() { <-s.exchangeSlots }() + case <-ctx.Done(): + return models.User{}, ctx.Err() + default: + return models.User{}, errors.New("OAuth exchange capacity is temporarily exhausted") + } var ( profile *OAuthUserProfile @@ -213,7 +247,7 @@ func (s *OAuthService) ResolveOAuthUser(ctx context.Context, provider, code, sta return models.User{}, fmt.Errorf("oauth authentication failed: %w", err) } - if profile.Email == "" { + if profile.Email == "" || !profile.EmailVerified { return models.User{}, errors.New("unable to retrieve a verified email address from " + provider) } @@ -225,47 +259,123 @@ func (s *OAuthService) ResolveOAuthUser(ctx context.Context, provider, code, sta } func (s *OAuthService) upsertOAuthUser(profile *OAuthUserProfile) (models.User, error) { + if profile == nil { + return models.User{}, errors.New("OAuth provider returned an invalid profile") + } + provider := strings.ToLower(strings.TrimSpace(profile.Provider)) + subject := strings.TrimSpace(profile.ID) cleanEmail := strings.ToLower(strings.TrimSpace(profile.Email)) - existingUser, exists := s.store.GetUserByEmail(cleanEmail) + if subject == "" || len(subject) > 512 { + return models.User{}, errors.New("OAuth provider returned an invalid subject identifier") + } + switch OAuthProvider(provider) { + case ProviderGoogle, ProviderGitHub, ProviderLinkedIn: + default: + return models.User{}, errors.New("OAuth provider is not supported") + } + if !profile.EmailVerified || len(cleanEmail) > maxOAuthEmailBytes || ValidateEmail(cleanEmail) != nil { + return models.User{}, errors.New("OAuth provider returned an invalid verified email address") + } + identities, ok := s.store.(store.OAuthIdentityRepository) + if !ok { + return models.User{}, errors.New("OAuth identity storage is not configured") + } + existingUser, exists, err := identities.LookupOAuthIdentity(provider, subject) + if err != nil { + return models.User{}, err + } if exists { - // Update user avatar if not already set or changed - if profile.Avatar != "" && (existingUser.Avatar == "" || strings.HasPrefix(existingUser.Avatar, "https://api.dicebear.com")) { - existingUser.Avatar = profile.Avatar - if existingUser.AuthProvider == "" || existingUser.AuthProvider == "local" { - existingUser.AuthProvider = profile.Provider - } - s.store.CreateUser(existingUser) + if !strings.EqualFold(existingUser.AuthProvider, provider) { + return models.User{}, errors.New("OAuth identity is linked to an incompatible account") + } + return s.updateOAuthAvatar(existingUser, profile.Avatar) + } + + existingUser, exists, err = lookupUserByEmail(s.store, cleanEmail) + if err != nil { + return models.User{}, err + } + + if exists { + if existingUser.AuthProvider == "" { + existingUser.AuthProvider = "local" } - return existingUser, nil + if !strings.EqualFold(existingUser.AuthProvider, provider) { + return models.User{}, errors.New("an account already exists for this email; sign in with its original method before linking another provider") + } + // Pre-subject OAuth rows cannot be linked safely from email alone: the + // provider may have reassigned that address since the account was created. + // Require an authenticated/manual migration instead of opening a one-time + // takeover window during the first login after rollout. + return models.User{}, errors.New("this legacy OAuth account requires an authenticated identity migration before social sign-in can continue") } - name := strings.TrimSpace(profile.Name) + name := truncateUTF8(strings.TrimSpace(profile.Name), maxOAuthNameBytes) if name == "" { parts := strings.Split(cleanEmail, "@") - name = strings.Title(parts[0]) + name = truncateUTF8(strings.Title(parts[0]), maxOAuthNameBytes) } - avatar := profile.Avatar + avatar := safeOAuthAvatar(profile.Avatar) if avatar == "" { avatar = fmt.Sprintf("https://api.dicebear.com/7.x/bottts/svg?seed=%s", url.QueryEscape(name)) } newUser := models.User{ - ID: "usr_" + uuid.New().String()[:8], + ID: "usr_" + uuid.NewString(), Name: name, Email: cleanEmail, PasswordHash: "", Role: models.RoleCandidate, Avatar: avatar, - AuthProvider: profile.Provider, + AuthProvider: provider, CreatedAt: time.Now(), } - created := s.store.CreateUser(newUser) + created, err := identities.CreateOAuthUserWithIdentity(newUser, provider, subject, cleanEmail) + if err != nil { + if errors.Is(err, store.ErrConflict) { + if racedUser, found, lookupErr := identities.LookupOAuthIdentity(provider, subject); lookupErr == nil && found { + return racedUser, nil + } + } + return models.User{}, err + } return created, nil } +func (s *OAuthService) updateOAuthAvatar(user models.User, avatar string) (models.User, error) { + avatar = safeOAuthAvatar(avatar) + if avatar == "" || (user.Avatar != "" && !strings.HasPrefix(user.Avatar, "https://api.dicebear.com")) { + return user, nil + } + user.Avatar = avatar + return s.store.CreateUser(user) +} + +func safeOAuthAvatar(raw string) string { + clean := strings.TrimSpace(raw) + if clean == "" || len(clean) > maxOAuthAvatarBytes { + return "" + } + parsed, err := url.Parse(clean) + if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.User != nil { + return "" + } + return clean +} + +func truncateUTF8(value string, maxBytes int) string { + if len(value) <= maxBytes { + return value + } + for maxBytes > 0 && !utf8.RuneStart(value[maxBytes]) { + maxBytes-- + } + return value[:maxBytes] +} + // --- Google Token & UserInfo Exchange --- func (s *OAuthService) exchangeGoogle(ctx context.Context, code string) (*OAuthUserProfile, error) { @@ -288,9 +398,12 @@ func (s *OAuthService) exchangeGoogle(ctx context.Context, code string) (*OAuthU } defer resp.Body.Close() - bodyBytes, _ := io.ReadAll(resp.Body) + bodyBytes, err := readOAuthResponse(resp.Body) + if err != nil { + return nil, fmt.Errorf("read google token response: %w", err) + } if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("google token exchange returned %d: %s", resp.StatusCode, string(bodyBytes)) + return nil, fmt.Errorf("google token exchange returned %d", resp.StatusCode) } var tokenRes struct { @@ -314,9 +427,12 @@ func (s *OAuthService) exchangeGoogle(ctx context.Context, code string) (*OAuthU } defer userResp.Body.Close() - userBytes, _ := io.ReadAll(userResp.Body) + userBytes, err := readOAuthResponse(userResp.Body) + if err != nil { + return nil, fmt.Errorf("read google userinfo response: %w", err) + } if userResp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("google userinfo returned %d: %s", userResp.StatusCode, string(userBytes)) + return nil, fmt.Errorf("google userinfo returned %d", userResp.StatusCode) } var gUser struct { @@ -331,11 +447,12 @@ func (s *OAuthService) exchangeGoogle(ctx context.Context, code string) (*OAuthU } return &OAuthUserProfile{ - ID: gUser.Sub, - Email: gUser.Email, - Name: gUser.Name, - Avatar: gUser.Picture, - Provider: string(ProviderGoogle), + ID: gUser.Sub, + Email: gUser.Email, + EmailVerified: gUser.EmailVerified, + Name: gUser.Name, + Avatar: gUser.Picture, + Provider: string(ProviderGoogle), }, nil } @@ -361,9 +478,12 @@ func (s *OAuthService) exchangeGitHub(ctx context.Context, code string) (*OAuthU } defer resp.Body.Close() - bodyBytes, _ := io.ReadAll(resp.Body) + bodyBytes, err := readOAuthResponse(resp.Body) + if err != nil { + return nil, fmt.Errorf("read github token response: %w", err) + } if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("github token exchange returned %d: %s", resp.StatusCode, string(bodyBytes)) + return nil, fmt.Errorf("github token exchange returned %d", resp.StatusCode) } var tokenRes struct { @@ -392,9 +512,12 @@ func (s *OAuthService) exchangeGitHub(ctx context.Context, code string) (*OAuthU } defer userResp.Body.Close() - userBytes, _ := io.ReadAll(userResp.Body) + userBytes, err := readOAuthResponse(userResp.Body) + if err != nil { + return nil, fmt.Errorf("read github user response: %w", err) + } if userResp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("github user info returned %d: %s", userResp.StatusCode, string(userBytes)) + return nil, fmt.Errorf("github user info returned %d", userResp.StatusCode) } var ghUser struct { @@ -408,11 +531,9 @@ func (s *OAuthService) exchangeGitHub(ctx context.Context, code string) (*OAuthU return nil, err } - email := ghUser.Email - if email == "" { - // Fetch emails list if private - email = s.fetchGitHubPrimaryEmail(ctx, tokenRes.AccessToken) - } + // The public user payload does not include a verification bit. Always use + // the email collection and accept only an address GitHub marks verified. + email := s.fetchGitHubPrimaryEmail(ctx, tokenRes.AccessToken) name := ghUser.Name if strings.TrimSpace(name) == "" { @@ -420,11 +541,12 @@ func (s *OAuthService) exchangeGitHub(ctx context.Context, code string) (*OAuthU } return &OAuthUserProfile{ - ID: strconv.FormatInt(ghUser.ID, 10), - Email: email, - Name: name, - Avatar: ghUser.AvatarURL, - Provider: string(ProviderGitHub), + ID: strconv.FormatInt(ghUser.ID, 10), + Email: email, + EmailVerified: email != "", + Name: name, + Avatar: ghUser.AvatarURL, + Provider: string(ProviderGitHub), }, nil } @@ -447,7 +569,14 @@ func (s *OAuthService) fetchGitHubPrimaryEmail(ctx context.Context, accessToken Primary bool `json:"primary"` Verified bool `json:"verified"` } - if err := json.NewDecoder(resp.Body).Decode(&emails); err != nil { + if resp.StatusCode != http.StatusOK { + return "" + } + body, err := readOAuthResponse(resp.Body) + if err != nil { + return "" + } + if err := json.Unmarshal(body, &emails); err != nil { return "" } @@ -456,8 +585,10 @@ func (s *OAuthService) fetchGitHubPrimaryEmail(ctx context.Context, accessToken return e.Email } } - if len(emails) > 0 { - return emails[0].Email + for _, e := range emails { + if e.Verified { + return e.Email + } } return "" } @@ -484,9 +615,12 @@ func (s *OAuthService) exchangeLinkedIn(ctx context.Context, code string) (*OAut } defer resp.Body.Close() - bodyBytes, _ := io.ReadAll(resp.Body) + bodyBytes, err := readOAuthResponse(resp.Body) + if err != nil { + return nil, fmt.Errorf("read linkedin token response: %w", err) + } if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("linkedin token exchange returned %d: %s", resp.StatusCode, string(bodyBytes)) + return nil, fmt.Errorf("linkedin token exchange returned %d", resp.StatusCode) } var tokenRes struct { @@ -514,9 +648,12 @@ func (s *OAuthService) exchangeLinkedIn(ctx context.Context, code string) (*OAut } defer userResp.Body.Close() - userBytes, _ := io.ReadAll(userResp.Body) + userBytes, err := readOAuthResponse(userResp.Body) + if err != nil { + return nil, fmt.Errorf("read linkedin userinfo response: %w", err) + } if userResp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("linkedin userinfo returned %d: %s", userResp.StatusCode, string(userBytes)) + return nil, fmt.Errorf("linkedin userinfo returned %d", userResp.StatusCode) } var liUser struct { @@ -538,10 +675,23 @@ func (s *OAuthService) exchangeLinkedIn(ctx context.Context, code string) (*OAut } return &OAuthUserProfile{ - ID: liUser.Sub, - Email: liUser.Email, - Name: name, - Avatar: liUser.Picture, - Provider: string(ProviderLinkedIn), + ID: liUser.Sub, + Email: liUser.Email, + EmailVerified: liUser.EmailVerified, + Name: name, + Avatar: liUser.Picture, + Provider: string(ProviderLinkedIn), }, nil } + +func readOAuthResponse(reader io.Reader) ([]byte, error) { + limited := io.LimitReader(reader, maxOAuthProviderResponseBytes+1) + payload, err := io.ReadAll(limited) + if err != nil { + return nil, err + } + if len(payload) > maxOAuthProviderResponseBytes { + return nil, errors.New("OAuth provider response exceeds size limit") + } + return payload, nil +} diff --git a/interviewhub-backend-go/internal/services/oauth_service_test.go b/interviewhub-backend-go/internal/services/oauth_service_test.go new file mode 100644 index 00000000..7d8c5b4d --- /dev/null +++ b/interviewhub-backend-go/internal/services/oauth_service_test.go @@ -0,0 +1,107 @@ +package services + +import ( + "strings" + "testing" + "time" + + "github.com/interviewhub/backend/internal/config" + "github.com/interviewhub/backend/internal/models" + "github.com/interviewhub/backend/internal/store" +) + +func oauthTestService(repository store.Repository) *OAuthService { + cfg := &config.Config{ + JWTSecret: "oauth-test-secret-that-is-long-enough", + AccessTokenTTL: 15 * time.Minute, + RefreshTokenTTL: 24 * time.Hour, + } + return NewOAuthService(repository, NewAuthService(repository, cfg), cfg) +} + +func TestOAuthIdentityUsesStableProviderSubject(t *testing.T) { + repository := store.NewMemoryStore() + service := oauthTestService(repository) + first, err := service.upsertOAuthUser(&OAuthUserProfile{ + ID: "provider-subject-1", Email: "person@example.com", EmailVerified: true, + Name: "Person", Provider: "google", + }) + if err != nil { + t.Fatalf("create OAuth identity: %v", err) + } + + resolved, err := service.upsertOAuthUser(&OAuthUserProfile{ + ID: "provider-subject-1", Email: "renamed@example.com", EmailVerified: true, + Name: "Renamed", Provider: "google", + }) + if err != nil || resolved.ID != first.ID { + t.Fatalf("stable subject resolved (%+v, %v), want user %s", resolved, err, first.ID) + } + + if _, err := service.upsertOAuthUser(&OAuthUserProfile{ + ID: "different-subject", Email: "person@example.com", EmailVerified: true, + Name: "Attacker", Provider: "google", + }); err == nil { + t.Fatal("different subject took over an existing provider email") + } +} + +func TestOAuthLegacyEmailRowRequiresExplicitBinding(t *testing.T) { + repository := store.NewMemoryStore() + _, err := repository.CreateUser(models.User{ + ID: "usr_legacy", Name: "Legacy", Email: "legacy@example.com", + Role: models.RoleCandidate, AuthProvider: "github", CreatedAt: time.Now(), + }) + if err != nil { + t.Fatal(err) + } + service := oauthTestService(repository) + _, err = service.upsertOAuthUser(&OAuthUserProfile{ + ID: "github-subject", Email: "legacy@example.com", EmailVerified: true, + Name: "Legacy", Provider: "github", + }) + if err == nil || !strings.Contains(err.Error(), "identity migration") { + t.Fatalf("legacy account error=%v, want explicit migration requirement", err) + } +} + +func TestOAuthProfileBoundsAndSanitizesProviderFields(t *testing.T) { + repository := store.NewMemoryStore() + service := oauthTestService(repository) + + if _, err := service.upsertOAuthUser(nil); err == nil { + t.Fatal("nil provider profile was accepted") + } + if _, err := service.upsertOAuthUser(&OAuthUserProfile{ + ID: "subject", Email: strings.Repeat("a", 255) + "@example.com", EmailVerified: true, + Provider: "google", + }); err == nil { + t.Fatal("oversized provider email was accepted") + } + if _, err := service.upsertOAuthUser(&OAuthUserProfile{ + ID: "subject", Email: "person@example.com", EmailVerified: false, + Provider: "google", + }); err == nil { + t.Fatal("unverified provider email was accepted") + } + + created, err := service.upsertOAuthUser(&OAuthUserProfile{ + ID: "subject", Email: "person@example.com", EmailVerified: true, + Name: strings.Repeat("界", 100), Avatar: "data:image/svg+xml,unsafe", Provider: "google", + }) + if err != nil { + t.Fatalf("create bounded OAuth profile: %v", err) + } + if len(created.Name) > maxOAuthNameBytes || !strings.HasPrefix(created.Avatar, "https://api.dicebear.com/") { + t.Fatalf("provider fields were not bounded/sanitized: %+v", created) + } +} + +func TestReadOAuthResponseEnforcesLimit(t *testing.T) { + if _, err := readOAuthResponse(strings.NewReader(strings.Repeat("x", maxOAuthProviderResponseBytes))); err != nil { + t.Fatalf("response exactly at limit failed: %v", err) + } + if _, err := readOAuthResponse(strings.NewReader(strings.Repeat("x", maxOAuthProviderResponseBytes+1))); err == nil { + t.Fatal("oversized provider response was accepted") + } +} diff --git a/interviewhub-backend-go/internal/services/persistence_errors_test.go b/interviewhub-backend-go/internal/services/persistence_errors_test.go new file mode 100644 index 00000000..6e42d15e --- /dev/null +++ b/interviewhub-backend-go/internal/services/persistence_errors_test.go @@ -0,0 +1,433 @@ +package services + +import ( + "errors" + "strings" + "sync" + "testing" + "time" + + "github.com/interviewhub/backend/internal/config" + "github.com/interviewhub/backend/internal/models" + "github.com/interviewhub/backend/internal/store" +) + +type failingPersistenceRepository struct { + store.Repository + createUserErr error + lookupUserByIDErr error + lookupUserByEmailErr error + consumeAuthCodeErr error + rotateRefreshErr error + revokeRefreshErr error + problemExistsErr error + lookupWorkspaceErr error + saveWorkspaceErr error + deleteWorkspaceErr error + saveSubmissionErr error + saveRefreshTokenErr error +} + +func (r *failingPersistenceRepository) LookupUserByID(id string) (models.User, bool, error) { + if r.lookupUserByIDErr != nil { + return models.User{}, false, r.lookupUserByIDErr + } + return r.Repository.(store.ErrorAwareUserLookup).LookupUserByID(id) +} + +func (r *failingPersistenceRepository) LookupUserByEmail(email string) (models.User, bool, error) { + if r.lookupUserByEmailErr != nil { + return models.User{}, false, r.lookupUserByEmailErr + } + return r.Repository.(store.ErrorAwareUserLookup).LookupUserByEmail(email) +} + +func (r *failingPersistenceRepository) ConsumeAuthCodeWithError(codeHash string) (string, bool, error) { + if r.consumeAuthCodeErr != nil { + return "", false, r.consumeAuthCodeErr + } + return r.Repository.(store.ErrorAwareAuthCodeConsumer).ConsumeAuthCodeWithError(codeHash) +} + +func (r *failingPersistenceRepository) RotateRefreshToken(tokenHash string, replacement models.RefreshToken, now time.Time) (models.RefreshToken, store.RefreshTokenRotationOutcome, error) { + if r.rotateRefreshErr != nil { + return models.RefreshToken{}, "", r.rotateRefreshErr + } + return r.Repository.RotateRefreshToken(tokenHash, replacement, now) +} + +func (r *failingPersistenceRepository) RevokeRefreshTokenFamilyByHash(tokenHash string, now time.Time) (bool, error) { + if r.revokeRefreshErr != nil { + return false, r.revokeRefreshErr + } + return r.Repository.(store.ErrorAwareRefreshTokenRevoker).RevokeRefreshTokenFamilyByHash(tokenHash, now) +} + +func (r *failingPersistenceRepository) ProblemExists(id string) (bool, error) { + if r.problemExistsErr != nil { + return false, r.problemExistsErr + } + return r.Repository.(store.ErrorAwareProblemExistence).ProblemExists(id) +} + +func (r *failingPersistenceRepository) LookupWorkspace(userID, problemID string, language models.Language) (models.Workspace, bool, error) { + if r.lookupWorkspaceErr != nil { + return models.Workspace{}, false, r.lookupWorkspaceErr + } + return r.Repository.(store.ErrorAwareWorkspaceLookup).LookupWorkspace(userID, problemID, language) +} + +func (r *failingPersistenceRepository) CreateUser(user models.User) (models.User, error) { + if r.createUserErr != nil { + return models.User{}, r.createUserErr + } + return r.Repository.CreateUser(user) +} + +func (r *failingPersistenceRepository) LookupOAuthIdentity(provider, subject string) (models.User, bool, error) { + return r.Repository.(store.OAuthIdentityRepository).LookupOAuthIdentity(provider, subject) +} + +func (r *failingPersistenceRepository) LinkOAuthIdentity(provider, subject, userID, providerEmail string) error { + return r.Repository.(store.OAuthIdentityRepository).LinkOAuthIdentity(provider, subject, userID, providerEmail) +} + +func (r *failingPersistenceRepository) CreateOAuthUserWithIdentity(user models.User, provider, subject, providerEmail string) (models.User, error) { + if r.createUserErr != nil { + return models.User{}, r.createUserErr + } + return r.Repository.(store.OAuthIdentityRepository).CreateOAuthUserWithIdentity(user, provider, subject, providerEmail) +} + +func (r *failingPersistenceRepository) SaveWorkspace(workspace models.Workspace) (models.Workspace, error) { + if r.saveWorkspaceErr != nil { + return models.Workspace{}, r.saveWorkspaceErr + } + return r.Repository.SaveWorkspace(workspace) +} + +func (r *failingPersistenceRepository) DeleteWorkspace(userID, problemID string, language models.Language) (bool, error) { + if r.deleteWorkspaceErr != nil { + return false, r.deleteWorkspaceErr + } + return r.Repository.DeleteWorkspace(userID, problemID, language) +} + +func (r *failingPersistenceRepository) SaveSubmission(submission models.Submission) (models.Submission, error) { + if r.saveSubmissionErr != nil { + return models.Submission{}, r.saveSubmissionErr + } + return r.Repository.SaveSubmission(submission) +} + +func (r *failingPersistenceRepository) SaveRefreshToken(token models.RefreshToken) error { + if r.saveRefreshTokenErr != nil { + return r.saveRefreshTokenErr + } + return r.Repository.SaveRefreshToken(token) +} + +func persistenceTestConfig() *config.Config { + return &config.Config{ + JWTSecret: "persistence-test-secret-that-is-long-enough", + AccessTokenTTL: 15 * time.Minute, + RefreshTokenTTL: time.Hour, + } +} + +func TestAuthServiceDoesNotReturnSessionWhenRefreshTokenWriteFails(t *testing.T) { + base := store.NewMemoryStore() + injected := errors.New("refresh persistence unavailable") + repository := &failingPersistenceRepository{Repository: base, saveRefreshTokenErr: injected} + auth := NewAuthService(repository, persistenceTestConfig()) + user, ok := base.GetUserByID("usr_cand_01") + if !ok { + t.Fatal("seed candidate missing") + } + + response, err := auth.CreateSession(user) + if !errors.Is(err, injected) { + t.Fatalf("CreateSession error=%v, want injected persistence error", err) + } + if response != nil { + t.Fatalf("CreateSession returned an unpersisted session: %+v", response) + } +} + +func TestAuthServiceConcurrentRefreshTreatsSecondUseAsReplay(t *testing.T) { + repository := store.NewMemoryStore() + auth := NewAuthService(repository, persistenceTestConfig()) + user, ok := repository.GetUserByID("usr_cand_01") + if !ok { + t.Fatal("seed candidate missing") + } + initial, err := auth.CreateSession(user) + if err != nil { + t.Fatalf("create initial session: %v", err) + } + + start := make(chan struct{}) + responses := make(chan *AuthResponse, 2) + errorsSeen := make(chan error, 2) + var wait sync.WaitGroup + for range 2 { + wait.Add(1) + go func() { + defer wait.Done() + <-start + response, err := auth.Refresh(initial.RefreshToken) + responses <- response + errorsSeen <- err + }() + } + close(start) + wait.Wait() + close(responses) + close(errorsSeen) + + var successful *AuthResponse + successCount := 0 + for response := range responses { + if response != nil { + successful = response + successCount++ + } + } + errorCount := 0 + for err := range errorsSeen { + if err != nil { + errorCount++ + if !strings.Contains(err.Error(), "reuse detected") { + t.Fatalf("concurrent refresh error=%q, want reuse detection", err) + } + } + } + if successCount != 1 || errorCount != 1 { + t.Fatalf("concurrent refresh successes=%d errors=%d, want one of each", successCount, errorCount) + } + if _, err := auth.Refresh(successful.RefreshToken); err == nil { + t.Fatal("replay did not revoke the successful replacement token's family") + } +} + +func TestAuthServicePropagatesUserWriteFailure(t *testing.T) { + injected := errors.New("user persistence unavailable") + repository := &failingPersistenceRepository{ + Repository: store.NewMemoryStore(), + createUserErr: injected, + } + auth := NewAuthService(repository, persistenceTestConfig()) + + response, err := auth.Register("Persistence Test", "persistence-test@example.com", "password123", models.RoleCandidate) + if !errors.Is(err, injected) { + t.Fatalf("Register error=%v, want injected persistence error", err) + } + if response != nil { + t.Fatalf("Register returned a session after user write failure: %+v", response) + } + if !errors.Is(err, ErrDependencyUnavailable) { + t.Fatalf("Register error=%v, want dependency-unavailable classification", err) + } +} + +func TestAuthServiceClassifiesReadAndRotationFailuresAsUnavailable(t *testing.T) { + injected := errors.New("driver connection details must stay private") + tests := []struct { + name string + repository *failingPersistenceRepository + operation func(*AuthService) error + }{ + { + name: "register lookup", + repository: &failingPersistenceRepository{ + Repository: store.NewMemoryStore(), + lookupUserByEmailErr: injected, + }, + operation: func(auth *AuthService) error { + _, err := auth.Register("Reader", "reader@example.com", "password123", models.RoleCandidate) + return err + }, + }, + { + name: "login lookup", + repository: &failingPersistenceRepository{ + Repository: store.NewMemoryStore(), + lookupUserByEmailErr: injected, + }, + operation: func(auth *AuthService) error { + _, err := auth.Login("candidate@google.com", "pass123") + return err + }, + }, + { + name: "refresh rotation", + repository: &failingPersistenceRepository{ + Repository: store.NewMemoryStore(), + rotateRefreshErr: injected, + }, + operation: func(auth *AuthService) error { + _, err := auth.Refresh("opaque-refresh-token") + return err + }, + }, + { + name: "handoff consumption", + repository: &failingPersistenceRepository{ + Repository: store.NewMemoryStore(), + consumeAuthCodeErr: injected, + }, + operation: func(auth *AuthService) error { + _, err := auth.ExchangeAuthCode("opaque-auth-code") + return err + }, + }, + } + + for _, testCase := range tests { + t.Run(testCase.name, func(t *testing.T) { + auth := NewAuthService(testCase.repository, persistenceTestConfig()) + err := testCase.operation(auth) + if !errors.Is(err, ErrDependencyUnavailable) || !errors.Is(err, injected) { + t.Fatalf("error=%v, want dependency-unavailable classification preserving injected cause", err) + } + }) + } +} + +func TestAuthServiceClassifiesPostCredentialUserLookupFailures(t *testing.T) { + injected := errors.New("user lookup failed after credential consumption") + + t.Run("refresh", func(t *testing.T) { + base := store.NewMemoryStore() + repository := &failingPersistenceRepository{Repository: base} + auth := NewAuthService(repository, persistenceTestConfig()) + user, ok := base.GetUserByID("usr_cand_01") + if !ok { + t.Fatal("seed candidate missing") + } + session, err := auth.CreateSession(user) + if err != nil { + t.Fatalf("CreateSession: %v", err) + } + repository.lookupUserByIDErr = injected + _, err = auth.Refresh(session.RefreshToken) + if !errors.Is(err, ErrDependencyUnavailable) || !errors.Is(err, injected) { + t.Fatalf("Refresh error=%v, want classified injected error", err) + } + }) + + t.Run("handoff", func(t *testing.T) { + base := store.NewMemoryStore() + repository := &failingPersistenceRepository{Repository: base} + auth := NewAuthService(repository, persistenceTestConfig()) + user, ok := base.GetUserByID("usr_cand_01") + if !ok { + t.Fatal("seed candidate missing") + } + code, err := auth.IssueAuthCode(user) + if err != nil { + t.Fatalf("IssueAuthCode: %v", err) + } + repository.lookupUserByIDErr = injected + _, err = auth.ExchangeAuthCode(code) + if !errors.Is(err, ErrDependencyUnavailable) || !errors.Is(err, injected) { + t.Fatalf("ExchangeAuthCode error=%v, want classified injected error", err) + } + }) +} + +func TestAuthServiceLogoutPropagatesStorageFailure(t *testing.T) { + injected := errors.New("logout storage unavailable") + repository := &failingPersistenceRepository{ + Repository: store.NewMemoryStore(), + revokeRefreshErr: injected, + } + err := NewAuthService(repository, persistenceTestConfig()).Logout("opaque-refresh-token") + if !errors.Is(err, ErrDependencyUnavailable) || !errors.Is(err, injected) { + t.Fatalf("Logout error=%v, want classified injected error", err) + } +} + +func TestWorkspaceServicePropagatesWriteFailure(t *testing.T) { + injected := errors.New("workspace persistence unavailable") + repository := &failingPersistenceRepository{ + Repository: store.NewMemoryStore(), + saveWorkspaceErr: injected, + } + service := NewWorkspaceService(repository) + + if _, err := service.Save("usr_cand_01", "parking-lot", validWorkspace()); !errors.Is(err, injected) || !errors.Is(err, ErrDependencyUnavailable) { + t.Fatalf("workspace save error=%v, want injected persistence error", err) + } +} + +func TestWorkspaceServiceClassifiesReadAndDeleteFailuresAsUnavailable(t *testing.T) { + injected := errors.New("workspace driver details must stay private") + + t.Run("problem existence", func(t *testing.T) { + repository := &failingPersistenceRepository{Repository: store.NewMemoryStore(), problemExistsErr: injected} + _, err := NewWorkspaceService(repository).Get("usr_cand_01", "parking-lot", models.LangGo) + if !errors.Is(err, ErrDependencyUnavailable) || !errors.Is(err, injected) { + t.Fatalf("Get error=%v, want classified injected error", err) + } + }) + + t.Run("workspace lookup", func(t *testing.T) { + repository := &failingPersistenceRepository{Repository: store.NewMemoryStore(), lookupWorkspaceErr: injected} + _, err := NewWorkspaceService(repository).Get("usr_cand_01", "parking-lot", models.LangGo) + if !errors.Is(err, ErrDependencyUnavailable) || !errors.Is(err, injected) { + t.Fatalf("Get error=%v, want classified injected error", err) + } + }) + + t.Run("delete", func(t *testing.T) { + repository := &failingPersistenceRepository{Repository: store.NewMemoryStore(), deleteWorkspaceErr: injected} + err := NewWorkspaceService(repository).Delete("usr_cand_01", "parking-lot", models.LangGo) + if !errors.Is(err, ErrDependencyUnavailable) || !errors.Is(err, injected) { + t.Fatalf("Delete error=%v, want classified injected error", err) + } + }) +} + +func TestSubmissionServicePropagatesWriteFailure(t *testing.T) { + injected := errors.New("submission persistence unavailable") + repository := &failingPersistenceRepository{ + Repository: store.NewMemoryStore(), + saveSubmissionErr: injected, + } + service := NewSubmissionService(repository) + problem, ok := repository.GetProblemByID("parking-lot") + if !ok { + t.Fatal("seed problem missing") + } + + _, err := service.RecordCompletedSubmission( + "usr_cand_01", + models.ExecutionRequest{ProblemID: "parking-lot", Language: models.LangGo}, + problem, + "sha256:test", + models.ExecutionResult{Status: "SUCCESS"}, + ) + if !errors.Is(err, injected) { + t.Fatalf("submission record error=%v, want injected persistence error", err) + } +} + +func TestOAuthServicePropagatesUserWriteFailure(t *testing.T) { + injected := errors.New("oauth user persistence unavailable") + base := store.NewMemoryStore() + repository := &failingPersistenceRepository{Repository: base, createUserErr: injected} + auth := NewAuthService(repository, persistenceTestConfig()) + service := NewOAuthService(repository, auth, persistenceTestConfig()) + + _, err := service.upsertOAuthUser(&OAuthUserProfile{ + ID: "provider-subject", + Email: "new-oauth-user@example.com", + Name: "OAuth User", + Provider: "google", + EmailVerified: true, + }) + if !errors.Is(err, injected) { + t.Fatalf("OAuth user persistence error=%v, want injected error", err) + } +} diff --git a/interviewhub-backend-go/internal/services/problem_read_integrity_test.go b/interviewhub-backend-go/internal/services/problem_read_integrity_test.go new file mode 100644 index 00000000..e5ddca8c --- /dev/null +++ b/interviewhub-backend-go/internal/services/problem_read_integrity_test.go @@ -0,0 +1,164 @@ +package services + +import ( + "errors" + "strings" + "testing" + + "github.com/interviewhub/backend/internal/models" + "github.com/interviewhub/backend/internal/store" +) + +type failingProblemReadRepository struct { + store.Repository + readErr error + listErr error + existsErr error + saveCalls int +} + +type failingProblemResetRepository struct { + store.Repository + err error +} + +type failingProblemDeleteRepository struct { + store.Repository + err error +} + +func (r *failingProblemDeleteRepository) DeleteProblem(string) (bool, error) { + return false, r.err +} + +func (r *failingProblemResetRepository) ResetProblemsToDefaults() ([]models.Problem, error) { + return nil, r.err +} + +func (r *failingProblemReadRepository) LookupAllProblems() ([]models.Problem, error) { + if r.listErr != nil { + return nil, r.listErr + } + return r.Repository.GetAllProblems(), nil +} + +func (r *failingProblemReadRepository) LookupProblemByID(id string) (models.Problem, bool, error) { + if r.readErr != nil { + return models.Problem{}, false, r.readErr + } + problem, found := r.Repository.GetProblemByID(id) + return problem, found, nil +} + +func (r *failingProblemReadRepository) ProblemExists(id string) (bool, error) { + if r.existsErr != nil { + return false, r.existsErr + } + _, found := r.Repository.GetProblemByID(id) + return found, nil +} + +func (r *failingProblemReadRepository) SaveProblem(problem models.Problem) (models.Problem, error) { + r.saveCalls++ + return r.Repository.SaveProblem(problem) +} + +func TestProblemUpdateRefusesIncompleteCatalogRead(t *testing.T) { + repository := &failingProblemReadRepository{ + Repository: store.NewMemoryStore(), + readErr: errors.New("decode hidden test row: private database detail"), + } + service := NewProblemService(repository) + + _, err := service.UpdateProblem("parking-lot", models.Problem{Title: "Changed"}) + if !errors.Is(err, ErrProblemPersistence) { + t.Fatalf("UpdateProblem error = %v, want ErrProblemPersistence", err) + } + if errors.Is(err, ErrProblemNotFound) { + t.Fatalf("incomplete read was misclassified as not found: %v", err) + } + if repository.saveCalls != 0 { + t.Fatalf("SaveProblem called %d times after an incomplete read, want 0", repository.saveCalls) + } + + stored, found := repository.Repository.GetProblemByID("parking-lot") + if !found || stored.Title == "Changed" { + t.Fatalf("catalog changed after refused update: found=%v problem=%+v", found, stored) + } +} + +func TestProblemReadsAndCreatePropagatePersistenceFailures(t *testing.T) { + injected := errors.New("database unavailable") + repository := &failingProblemReadRepository{ + Repository: store.NewMemoryStore(), + readErr: injected, + listErr: injected, + existsErr: injected, + } + service := NewProblemService(repository) + + if _, err := service.GetProblems(ProblemFilter{}); !errors.Is(err, ErrProblemPersistence) { + t.Fatalf("GetProblems error = %v, want ErrProblemPersistence", err) + } + if _, err := service.GetProblemByID("parking-lot"); !errors.Is(err, ErrProblemPersistence) { + t.Fatalf("GetProblemByID error = %v, want ErrProblemPersistence", err) + } + if _, err := service.CreateProblem(models.Problem{ID: "safe-slug", Title: "Safe title"}); !errors.Is(err, ErrProblemPersistence) { + t.Fatalf("CreateProblem error = %v, want ErrProblemPersistence", err) + } + if repository.saveCalls != 0 { + t.Fatalf("SaveProblem called %d times after failed reads, want 0", repository.saveCalls) + } +} + +func TestProblemResetClassifiesProtectedArtifactsAccurately(t *testing.T) { + tests := []struct { + name string + storageErr error + wantMessage string + }{ + {name: "submission", storageErr: store.ErrProblemReferenced, wantMessage: "submission history"}, + {name: "workspace", storageErr: store.ErrProblemWorkspaceReferenced, wantMessage: "candidate workspaces"}, + } + for _, testCase := range tests { + t.Run(testCase.name, func(t *testing.T) { + service := NewProblemService(&failingProblemResetRepository{ + Repository: store.NewMemoryStore(), + err: testCase.storageErr, + }) + _, err := service.ResetToDefaults() + if !errors.Is(err, ErrProblemConflict) { + t.Fatalf("ResetToDefaults error = %v, want ErrProblemConflict", err) + } + if !strings.Contains(err.Error(), testCase.wantMessage) { + t.Fatalf("ResetToDefaults error = %v, want %q classification", err, testCase.wantMessage) + } + }) + } +} + +func TestProblemDeleteClassifiesProtectedArtifactsAccurately(t *testing.T) { + tests := []struct { + name string + storageErr error + wantMessage string + }{ + {name: "submission", storageErr: store.ErrProblemReferenced, wantMessage: "submission history"}, + {name: "workspace", storageErr: store.ErrProblemWorkspaceReferenced, wantMessage: "candidate workspaces"}, + } + for _, testCase := range tests { + t.Run(testCase.name, func(t *testing.T) { + service := NewProblemService(&failingProblemDeleteRepository{ + Repository: store.NewMemoryStore(), + err: testCase.storageErr, + }) + err := service.DeleteProblem("protected") + if !errors.Is(err, ErrProblemConflict) { + t.Fatalf("DeleteProblem error = %v, want ErrProblemConflict", err) + } + if !strings.Contains(err.Error(), testCase.wantMessage) { + t.Fatalf("DeleteProblem error = %v, want %q classification", err, testCase.wantMessage) + } + }) + } +} diff --git a/interviewhub-backend-go/internal/services/problem_service.go b/interviewhub-backend-go/internal/services/problem_service.go index dcb77ee6..a1af12d6 100644 --- a/interviewhub-backend-go/internal/services/problem_service.go +++ b/interviewhub-backend-go/internal/services/problem_service.go @@ -3,7 +3,9 @@ package services import ( "errors" "fmt" + "regexp" "strings" + "time" "github.com/interviewhub/backend/internal/models" "github.com/interviewhub/backend/internal/store" @@ -15,6 +17,18 @@ type ProblemFilter struct { Search string } +var ( + ErrInvalidProblem = errors.New("invalid problem") + ErrProblemNotFound = errors.New("problem not found") + ErrProblemConflict = errors.New("problem conflict") + ErrProblemPersistence = errors.New("problem persistence failed") +) + +var ( + problemSlugPattern = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`) + problemTestIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$`) +) + type ProblemService struct { store store.Repository } @@ -23,8 +37,11 @@ func NewProblemService(s store.Repository) *ProblemService { return &ProblemService{store: s} } -func (p *ProblemService) GetProblems(filter ProblemFilter) []models.Problem { - problems := p.store.GetAllProblems() +func (p *ProblemService) GetProblems(filter ProblemFilter) ([]models.Problem, error) { + problems, err := p.lookupAllProblems() + if err != nil { + return nil, problemPersistenceError(err) + } var result []models.Problem for _, prob := range problems { @@ -57,13 +74,16 @@ func (p *ProblemService) GetProblems(filter ProblemFilter) []models.Problem { result = append(result, sanitizeProblemForClient(prob)) } - return result + return result, nil } func (p *ProblemService) GetProblemByID(id string) (models.Problem, error) { - prob, ok := p.store.GetProblemByID(id) + prob, ok, err := p.lookupProblemByID(id) + if err != nil { + return models.Problem{}, problemPersistenceError(err) + } if !ok { - return models.Problem{}, fmt.Errorf("problem with id '%s' not found", id) + return models.Problem{}, fmt.Errorf("%w: %s", ErrProblemNotFound, id) } return sanitizeProblemForClient(prob), nil } @@ -83,35 +103,201 @@ func sanitizeProblemForClient(prob models.Problem) models.Problem { } func (p *ProblemService) CreateProblem(prob models.Problem) (models.Problem, error) { - if prob.ID == "" || prob.Title == "" { - return models.Problem{}, errors.New("problem ID (slug) and Title are required") - } - prob.ID = strings.ToLower(strings.ReplaceAll(strings.TrimSpace(prob.ID), " ", "-")) - if _, exists := p.store.GetProblemByID(prob.ID); exists { - return models.Problem{}, fmt.Errorf("a problem with slug ID '%s' already exists", prob.ID) + if err := validateProblemPayload(prob, true); err != nil { + return models.Problem{}, err + } + prob.Title = strings.TrimSpace(prob.Title) + exists, err := p.problemExists(prob.ID) + if err != nil { + return models.Problem{}, problemPersistenceError(err) + } + if exists { + return models.Problem{}, fmt.Errorf("%w: problem with slug ID %q already exists", ErrProblemConflict, prob.ID) } - return p.store.SaveProblem(prob), nil + saved, err := p.store.SaveProblem(prob) + if err != nil { + return models.Problem{}, fmt.Errorf("%w: %v", ErrProblemPersistence, err) + } + return sanitizeProblemForClient(saved), nil } func (p *ProblemService) UpdateProblem(id string, prob models.Problem) (models.Problem, error) { - current, ok := p.store.GetProblemByID(id) + expectedUpdatedAt := prob.UpdatedAt + current, ok, err := p.lookupProblemByID(id) + if err != nil { + return models.Problem{}, problemPersistenceError(err) + } if !ok { - return models.Problem{}, fmt.Errorf("problem '%s' does not exist", id) + return models.Problem{}, fmt.Errorf("%w: %s", ErrProblemNotFound, id) + } + if expectedUpdatedAt.IsZero() { + return models.Problem{}, fmt.Errorf("%w: updatedAt is required; reload the problem before saving", ErrInvalidProblem) } prob.ID = current.ID // Prevent slug mutation - return p.store.SaveProblem(prob), nil + // The admin editor receives the public catalog representation, so an + // ordinary full-document PUT cannot be allowed to replace server-owned + // hidden assertions or omitted storage references with empty values. + prob.HiddenTestCases = serverOwnedHiddenTests(current) + if prob.BoilerplateRefs == nil { + prob.BoilerplateRefs = cloneBoilerplateRefs(current.BoilerplateRefs) + } + if err := validateProblemPayload(prob, false); err != nil { + return models.Problem{}, err + } + prob.Title = strings.TrimSpace(prob.Title) + saved, err := p.store.SaveProblemIfUnchanged(prob, expectedUpdatedAt) + if err != nil { + if errors.Is(err, store.ErrConflict) { + return models.Problem{}, fmt.Errorf("%w: problem changed after it was loaded; reload and retry", ErrProblemConflict) + } + return models.Problem{}, fmt.Errorf("%w: %v", ErrProblemPersistence, err) + } + return sanitizeProblemForClient(saved), nil +} + +func (p *ProblemService) lookupAllProblems() ([]models.Problem, error) { + if reader, ok := p.store.(store.ErrorAwareProblemReader); ok { + return reader.LookupAllProblems() + } + return p.store.GetAllProblems(), nil +} + +func (p *ProblemService) lookupProblemByID(id string) (models.Problem, bool, error) { + if reader, ok := p.store.(store.ErrorAwareProblemReader); ok { + return reader.LookupProblemByID(id) + } + problem, found := p.store.GetProblemByID(id) + return problem, found, nil +} + +func (p *ProblemService) problemExists(id string) (bool, error) { + if checker, ok := p.store.(store.ErrorAwareProblemExistence); ok { + return checker.ProblemExists(id) + } + _, found := p.store.GetProblemByID(id) + return found, nil +} + +func problemPersistenceError(err error) error { + return fmt.Errorf("%w: %v", ErrProblemPersistence, err) +} + +func cloneBoilerplateRefs(source map[models.Language]string) map[models.Language]string { + if source == nil { + return nil + } + cloned := make(map[models.Language]string, len(source)) + for language, reference := range source { + cloned[language] = reference + } + return cloned +} + +func validateProblemPayload(problem models.Problem, validateID bool) error { + if validateID && (len(problem.ID) < 3 || len(problem.ID) > 80 || !problemSlugPattern.MatchString(problem.ID)) { + return fmt.Errorf("%w: ID must be a 3-80 character lowercase slug", ErrInvalidProblem) + } + title := strings.TrimSpace(problem.Title) + if title == "" || len(title) > 160 { + return fmt.Errorf("%w: title must contain 1-160 characters", ErrInvalidProblem) + } + for language := range problem.StarterFiles { + if !isSupportedLanguage(language) { + return fmt.Errorf("%w: unsupported starter-file language %q", ErrInvalidProblem, language) + } + } + + seenTestIDs := make(map[string]struct{}, len(problem.TestCases)+len(problem.HiddenTestCases)) + validateTest := func(testCase models.TestCase) error { + if !problemTestIDPattern.MatchString(testCase.ID) { + return fmt.Errorf("%w: test case ID %q is invalid", ErrInvalidProblem, testCase.ID) + } + if _, duplicate := seenTestIDs[testCase.ID]; duplicate { + return fmt.Errorf("%w: duplicate test case ID %q", ErrInvalidProblem, testCase.ID) + } + seenTestIDs[testCase.ID] = struct{}{} + if strings.TrimSpace(testCase.ExpectedOutput) == "" { + return fmt.Errorf("%w: test case %q requires expectedOutput", ErrInvalidProblem, testCase.ID) + } + if testCase.TimeoutMs < 0 || testCase.TimeoutMs > int(maxCatalogTestTimeout/time.Millisecond) { + return fmt.Errorf("%w: test case %q timeout is outside the supported range", ErrInvalidProblem, testCase.ID) + } + if testCase.ConcurrentWorkers < 0 || testCase.ConcurrentWorkers > maxConcurrentTestWorkers { + return fmt.Errorf("%w: test case %q concurrentWorkers is outside the supported range", ErrInvalidProblem, testCase.ID) + } + return nil + } + for _, testCase := range problem.TestCases { + if testCase.Visibility == models.TestHidden { + return fmt.Errorf("%w: hidden tests must use the server-owned hidden test collection", ErrInvalidProblem) + } + if err := validateTest(testCase); err != nil { + return err + } + } + for _, testCase := range problem.HiddenTestCases { + if err := validateTest(testCase); err != nil { + return err + } + } + return nil +} + +func serverOwnedHiddenTests(problem models.Problem) []models.TestCase { + hidden := make([]models.TestCase, 0, len(problem.TestCases)+len(problem.HiddenTestCases)) + seen := make(map[string]struct{}) + appendHidden := func(testCase models.TestCase) { + if testCase.Visibility != models.TestHidden { + return + } + if _, duplicate := seen[testCase.ID]; duplicate && testCase.ID != "" { + return + } + seen[testCase.ID] = struct{}{} + hidden = append(hidden, testCase) + } + for _, testCase := range problem.TestCases { + appendHidden(testCase) + } + for _, testCase := range problem.HiddenTestCases { + testCase.Visibility = models.TestHidden + appendHidden(testCase) + } + return hidden } func (p *ProblemService) DeleteProblem(id string) error { - if !p.store.DeleteProblem(id) { - return fmt.Errorf("problem '%s' not found", id) + deleted, err := p.store.DeleteProblem(id) + if err != nil { + return problemWriteError(err) + } + if !deleted { + return fmt.Errorf("%w: %s", ErrProblemNotFound, id) } return nil } -func (p *ProblemService) ResetToDefaults() []models.Problem { - return p.store.ResetProblemsToDefaults() +func (p *ProblemService) ResetToDefaults() ([]models.Problem, error) { + problems, err := p.store.ResetProblemsToDefaults() + if err != nil { + return nil, problemWriteError(err) + } + result := make([]models.Problem, 0, len(problems)) + for _, problem := range problems { + result = append(result, sanitizeProblemForClient(problem)) + } + return result, nil +} + +func problemWriteError(err error) error { + if errors.Is(err, store.ErrProblemReferenced) { + return fmt.Errorf("%w: submission history references an obsolete catalog problem", ErrProblemConflict) + } + if errors.Is(err, store.ErrProblemWorkspaceReferenced) { + return fmt.Errorf("%w: candidate workspaces reference an obsolete catalog problem", ErrProblemConflict) + } + return problemPersistenceError(err) } diff --git a/interviewhub-backend-go/internal/services/problem_service_test.go b/interviewhub-backend-go/internal/services/problem_service_test.go new file mode 100644 index 00000000..7b07628b --- /dev/null +++ b/interviewhub-backend-go/internal/services/problem_service_test.go @@ -0,0 +1,126 @@ +package services + +import ( + "errors" + "testing" + "time" + + "github.com/interviewhub/backend/internal/models" + "github.com/interviewhub/backend/internal/store" +) + +func TestProblemUpdatePreservesAndDoesNotExposeHiddenTests(t *testing.T) { + repository := store.NewMemoryStore() + if _, err := repository.SaveProblem(models.Problem{ + ID: "hidden-update", + Title: "Before", + TestCases: []models.TestCase{{ + ID: "public", + Visibility: models.TestPublic, + ExpectedOutput: "public-output", + }, { + ID: "legacy-hidden", + Visibility: models.TestHidden, + ExpectedOutput: "legacy-secret", + }}, + HiddenTestCases: []models.TestCase{{ + ID: "hidden", + Visibility: models.TestHidden, + ExpectedOutput: "server-secret", + }}, + }); err != nil { + t.Fatal(err) + } + service := NewProblemService(repository) + + clientProblem, err := service.GetProblemByID("hidden-update") + if err != nil { + t.Fatal(err) + } + if len(clientProblem.HiddenTestCases) != 0 { + t.Fatalf("read response exposed hidden tests: %+v", clientProblem.HiddenTestCases) + } + clientProblem.Title = "After" + updated, err := service.UpdateProblem("hidden-update", clientProblem) + if err != nil { + t.Fatal(err) + } + if len(updated.HiddenTestCases) != 0 { + t.Fatalf("update response exposed hidden tests: %+v", updated.HiddenTestCases) + } + + stored, ok := repository.GetProblemByID("hidden-update") + if !ok { + t.Fatal("updated problem disappeared") + } + if stored.Title != "After" || len(stored.HiddenTestCases) != 2 || stored.HiddenTestCases[0].ExpectedOutput != "legacy-secret" || stored.HiddenTestCases[1].ExpectedOutput != "server-secret" { + t.Fatalf("update did not preserve hidden tests: %+v", stored) + } +} + +func TestProblemUpdateRejectsStaleRevisionWithoutOverwritingWinner(t *testing.T) { + repository := store.NewMemoryStore() + service := NewProblemService(repository) + + firstEditor, err := service.GetProblemByID("parking-lot") + if err != nil { + t.Fatal(err) + } + staleEditor := firstEditor + + firstEditor.Title = "First editor wins" + winner, err := service.UpdateProblem("parking-lot", firstEditor) + if err != nil { + t.Fatalf("first update: %v", err) + } + if !winner.UpdatedAt.After(staleEditor.UpdatedAt) { + t.Fatalf("updated revision %s did not advance past %s", winner.UpdatedAt, staleEditor.UpdatedAt) + } + + staleEditor.Title = "Stale editor overwrote winner" + if _, err := service.UpdateProblem("parking-lot", staleEditor); !errors.Is(err, ErrProblemConflict) { + t.Fatalf("stale update error = %v, want ErrProblemConflict", err) + } + stored, found := repository.GetProblemByID("parking-lot") + if !found || stored.Title != "First editor wins" || !stored.UpdatedAt.Equal(winner.UpdatedAt) { + t.Fatalf("stale update changed winner: found=%v problem=%+v", found, stored) + } +} + +func TestProblemUpdateRequiresCatalogRevision(t *testing.T) { + repository := store.NewMemoryStore() + service := NewProblemService(repository) + problem, err := service.GetProblemByID("parking-lot") + if err != nil { + t.Fatal(err) + } + problem.UpdatedAt = time.Time{} + if _, err := service.UpdateProblem("parking-lot", problem); !errors.Is(err, ErrInvalidProblem) { + t.Fatalf("revisionless update error = %v, want ErrInvalidProblem", err) + } +} + +func TestProblemPayloadValidationRejectsUnsafeCatalogData(t *testing.T) { + service := NewProblemService(store.NewMemoryStore()) + cases := []models.Problem{ + {ID: "../x", Title: "Unsafe route"}, + {ID: "valid-slug", Title: " "}, + {ID: "valid-slug", Title: "Unsupported language", StarterFiles: map[models.Language][]models.FileNode{"ruby": nil}}, + {ID: "valid-slug", Title: "Missing assertion", TestCases: []models.TestCase{{ID: "tc-1"}}}, + {ID: "valid-slug", Title: "Unsafe test ID", TestCases: []models.TestCase{{ID: "../tc", ExpectedOutput: "ok"}}}, + } + for _, problem := range cases { + if _, err := service.CreateProblem(problem); !errors.Is(err, ErrInvalidProblem) { + t.Fatalf("CreateProblem(%+v) error=%v, want ErrInvalidProblem", problem, err) + } + } + + current, err := service.GetProblemByID("parking-lot") + if err != nil { + t.Fatal(err) + } + current.Title = "" + if _, err := service.UpdateProblem("parking-lot", current); !errors.Is(err, ErrInvalidProblem) { + t.Fatalf("blank-title update error=%v, want ErrInvalidProblem", err) + } +} diff --git a/interviewhub-backend-go/internal/services/refresh_security_test.go b/interviewhub-backend-go/internal/services/refresh_security_test.go new file mode 100644 index 00000000..bb5208a6 --- /dev/null +++ b/interviewhub-backend-go/internal/services/refresh_security_test.go @@ -0,0 +1,79 @@ +package services + +import ( + "errors" + "strings" + "sync" + "testing" + "time" + + "github.com/interviewhub/backend/internal/models" + "github.com/interviewhub/backend/internal/store" +) + +type generationLimitedRefreshRepository struct { + store.Repository +} + +func (r *generationLimitedRefreshRepository) RotateRefreshToken(string, models.RefreshToken, time.Time) (models.RefreshToken, store.RefreshTokenRotationOutcome, error) { + return models.RefreshToken{FamilyID: "fam_limited"}, store.RefreshTokenGenerationLimit, nil +} + +func TestAuthServiceRequiresSignInAtRefreshGenerationLimit(t *testing.T) { + auth := NewAuthService(&generationLimitedRefreshRepository{Repository: store.NewMemoryStore()}, persistenceTestConfig()) + response, err := auth.Refresh("opaque-refresh-token") + if response != nil { + t.Fatalf("Refresh returned a session at the generation limit: %+v", response) + } + if !errors.Is(err, ErrInvalidCredentials) || !strings.Contains(err.Error(), "sign in again") { + t.Fatalf("Refresh error = %v, want sign-in-required invalid credentials", err) + } +} + +func TestAuthServiceConcurrentLogoutAndRefreshNeverLeaveUsableReplacement(t *testing.T) { + repository := store.NewMemoryStore() + auth := NewAuthService(repository, persistenceTestConfig()) + user, found := repository.GetUserByID("usr_cand_01") + if !found { + t.Fatal("seed candidate missing") + } + session, err := auth.CreateSession(user) + if err != nil { + t.Fatal(err) + } + + start := make(chan struct{}) + refreshResponse := make(chan *AuthResponse, 1) + refreshError := make(chan error, 1) + logoutError := make(chan error, 1) + var wait sync.WaitGroup + wait.Add(2) + go func() { + defer wait.Done() + <-start + response, err := auth.Refresh(session.RefreshToken) + refreshResponse <- response + refreshError <- err + }() + go func() { + defer wait.Done() + <-start + logoutError <- auth.Logout(session.RefreshToken) + }() + close(start) + wait.Wait() + + if err := <-logoutError; err != nil { + t.Fatalf("Logout error: %v", err) + } + response := <-refreshResponse + rotationErr := <-refreshError + if response == nil && rotationErr == nil { + t.Fatal("concurrent refresh returned neither a response nor an error") + } + if response != nil { + if _, err := auth.Refresh(response.RefreshToken); err == nil { + t.Fatal("logout/refresh race left the replacement credential usable") + } + } +} diff --git a/interviewhub-backend-go/internal/services/submission_service.go b/interviewhub-backend-go/internal/services/submission_service.go index 92886011..e36de58d 100644 --- a/interviewhub-backend-go/internal/services/submission_service.go +++ b/interviewhub-backend-go/internal/services/submission_service.go @@ -1,6 +1,11 @@ package services import ( + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "fmt" "strings" "time" @@ -9,93 +14,335 @@ import ( "github.com/interviewhub/backend/internal/store" ) +// SubmissionService owns grading-record persistence. It does not execute code; +// ExecutionService supplies an immutable completed result through the +// SubmissionRecorder contract. type SubmissionService struct { store store.Repository - exec *ExecutionService } -func NewSubmissionService(s store.Repository, e *ExecutionService) *SubmissionService { - return &SubmissionService{store: s, exec: e} +func NewSubmissionService(repository store.Repository) *SubmissionService { + return &SubmissionService{store: repository} } -// SubmitAndGrade runs the full test suite and records candidate submission -func (s *SubmissionService) SubmitAndGrade(userID string, req models.ExecutionRequest) models.Submission { - result := s.exec.ExecuteCode(req) +// RecordCompletedSubmission converts a terminal execution result into a +// durable submission. Hidden expected values are removed before persistence so +// no client-facing read path can disclose catalog-owned assertions. +func (s *SubmissionService) RecordCompletedSubmission(userID string, request models.ExecutionRequest, problem models.Problem, evaluationRevision string, result models.ExecutionResult) (models.Submission, error) { + if strings.TrimSpace(userID) == "" { + return models.Submission{}, errors.New("authenticated user is required for submissions") + } + user, ok := s.store.GetUserByID(userID) + if !ok { + return models.Submission{}, errors.New("submission user not found") + } + if problem.ID != request.ProblemID { + return models.Submission{}, errors.New("submission evaluation snapshot does not match the requested problem") + } + + result.TestResults = redactHiddenExpectedValues(problem, result.TestResults) + totalTests := result.TotalTests + if totalTests < len(result.TestResults) { + totalTests = len(result.TestResults) + } + passedTests := result.PassedTests + if passedTests < 0 { + passedTests = 0 + } + if passedTests > totalTests { + passedTests = totalTests + } score := 0 - if result.TotalTests > 0 { - score = int((float64(result.PassedTests) / float64(result.TotalTests)) * 100) + if totalTests > 0 { + score = passedTests * 100 / totalTests } - submissionStatus := "ACCEPTED" - if result.Status != "SUCCESS" { - submissionStatus = result.Status - } else if result.PassedTests < result.TotalTests { - submissionStatus = "WRONG_ANSWER" + submission := models.Submission{ + ID: "sub_" + uuid.NewString(), + UserID: userID, + UserEmail: user.Email, + ProblemID: request.ProblemID, + ProblemTitle: problem.Title, + EvaluationRevision: evaluationRevision, + Language: request.Language, + Files: request.Files, + Status: submissionStatus(result.Status, passedTests, totalTests), + PassedTests: passedTests, + TotalTests: totalTests, + Score: score, + DurationMs: result.DurationMs, + TestResults: result.TestResults, + ConsoleOutput: consoleLines(result), + CreatedAt: time.Now().UTC(), } - probTitle := "LLD Problem" - if p, ok := s.store.GetProblemByID(req.ProblemID); ok { - probTitle = p.Title + saved, err := s.store.SaveSubmission(submission) + if err != nil { + return models.Submission{}, fmt.Errorf("persist submission: %w", err) + } + if saved.ID == "" { + return models.Submission{}, errors.New("submission persistence returned an empty id") } + return saved, nil +} - userEmail := "candidate@example.com" - if u, ok := s.store.GetUserByID(userID); ok { - userEmail = u.Email +func submissionStatus(executionStatus string, passedTests, totalTests int) string { + switch executionStatus { + case "SUCCESS": + if totalTests == 0 { + return models.SubmissionStatusEvaluationUnavailable + } + if passedTests == totalTests { + return models.SubmissionStatusAccepted + } + return models.SubmissionStatusWrongAnswer + case "COMPILE_ERROR": + return models.SubmissionStatusCompileError + case "TIMEOUT": + return models.SubmissionStatusTimeLimitExceeded + case "OUTPUT_LIMIT_EXCEEDED": + return models.SubmissionStatusOutputLimitExceeded + case "RUNTIME_UNAVAILABLE": + return models.SubmissionStatusRuntimeUnavailable + case "EVALUATION_UNAVAILABLE": + return models.SubmissionStatusEvaluationUnavailable + default: + return models.SubmissionStatusRuntimeError } +} - var consoleOutput []string +func consoleLines(result models.ExecutionResult) []string { + lines := make([]string, 0) if result.Stdout != "" { - consoleOutput = append(consoleOutput, strings.Split(result.Stdout, "\n")...) + lines = append(lines, strings.Split(result.Stdout, "\n")...) } if result.Stderr != "" { - consoleOutput = append(consoleOutput, "[STDERR] "+result.Stderr) + lines = append(lines, "[STDERR] "+result.Stderr) } + return lines +} - submission := models.Submission{ - ID: "sub_" + uuid.New().String()[:8], - UserID: userID, - UserEmail: userEmail, - ProblemID: req.ProblemID, - ProblemTitle: probTitle, - Language: req.Language, - Files: req.Files, - Status: submissionStatus, - PassedTests: result.PassedTests, - TotalTests: result.TotalTests, - Score: score, - DurationMs: result.DurationMs, - TestResults: result.TestResults, - ConsoleOutput: consoleOutput, - CreatedAt: time.Now(), - } - - return s.store.SaveSubmission(submission) -} - -// GetUserSubmissions returns all attempts by a candidate -func (s *SubmissionService) GetUserSubmissions(userID, problemID string) []models.Submission { - all := s.store.GetAllSubmissions() - var result []models.Submission - - for _, sub := range all { - if userID != "" && sub.UserID != userID { - continue +func redactHiddenExpectedValues(problem models.Problem, results []models.TestResult) []models.TestResult { + hiddenIDs := make(map[string]int, len(problem.HiddenTestCases)) + nextOrdinal := 1 + for _, testCase := range problem.TestCases { + if testCase.Visibility == models.TestHidden { + hiddenIDs[testCase.ID] = nextOrdinal + nextOrdinal++ } - if problemID != "" && sub.ProblemID != problemID { - continue + } + for _, testCase := range problem.HiddenTestCases { + if _, exists := hiddenIDs[testCase.ID]; !exists { + hiddenIDs[testCase.ID] = nextOrdinal + nextOrdinal++ + } + } + + redacted := append([]models.TestResult(nil), results...) + for index := range redacted { + if ordinal, hidden := hiddenIDs[redacted[index].TestCaseID]; hidden { + redacted[index].TestCaseID = fmt.Sprintf("hidden-%d", ordinal) + redacted[index].Name = fmt.Sprintf("Hidden test %d", ordinal) + redacted[index].Expected = "" + redacted[index].Actual = "" + if !redacted[index].Passed { + redacted[index].Error = "hidden test failed" + } + } + } + return redacted +} + +const ( + maxSubmissionIDLength = 200 + maxSubmissionProblemLength = 160 + maxSubmissionCursorLength = 1024 +) + +type SubmissionListOptions struct { + ProblemID string + Cursor string + Limit int +} + +type submissionCursor struct { + CreatedAtUnixNano int64 `json:"createdAtUnixNano"` + ID string `json:"id"` + ProblemID string `json:"problemId,omitempty"` + Scope string `json:"scope"` +} + +// ListSubmissions returns a bounded lightweight history page. Ownership, +// problem filtering, keyset pagination, ordering, and projection are all +// delegated to the repository so PostgreSQL never scans and decodes every +// complete submission document in application memory. +func (s *SubmissionService) ListSubmissions(ownerUserID string, options SubmissionListOptions) (models.SubmissionPage, error) { + problemID := strings.TrimSpace(options.ProblemID) + if len(problemID) > maxSubmissionProblemLength { + return models.SubmissionPage{}, serviceError(ErrInvalidInput, "problemId is too long", nil) + } + limit := options.Limit + if limit == 0 { + limit = store.DefaultSubmissionPageSize + } + if limit < 1 || limit > store.MaxSubmissionPageSize { + return models.SubmissionPage{}, serviceError(ErrInvalidInput, fmt.Sprintf("limit must be between 1 and %d", store.MaxSubmissionPageSize), nil) + } + + query := store.SubmissionPageQuery{ + OwnerUserID: ownerUserID, + ProblemID: problemID, + Limit: limit, + } + if options.Cursor != "" { + cursor, err := decodeSubmissionCursor(options.Cursor) + if err != nil || cursor.ProblemID != problemID || cursor.Scope != submissionCursorScope(ownerUserID) { + return models.SubmissionPage{}, serviceError(ErrInvalidInput, "invalid submission cursor", nil) } - result = append(result, sub) + query.BeforeCreatedAt = time.Unix(0, cursor.CreatedAtUnixNano).UTC() + query.BeforeID = cursor.ID + } + + result, err := s.store.LookupSubmissionPage(query) + if err != nil { + return models.SubmissionPage{}, serviceError(ErrDependencyUnavailable, "submission storage is unavailable", err) + } + page := models.SubmissionPage{Items: result.Items} + if page.Items == nil { + page.Items = make([]models.SubmissionSummary, 0) + } + if result.HasMore && len(page.Items) > 0 { + last := page.Items[len(page.Items)-1] + page.NextCursor = encodeSubmissionCursor(submissionCursor{ + CreatedAtUnixNano: last.CreatedAt.UnixNano(), + ID: last.ID, + ProblemID: problemID, + Scope: submissionCursorScope(ownerUserID), + }) + } + return page, nil +} + +func decodeSubmissionCursor(encoded string) (submissionCursor, error) { + if len(encoded) == 0 || len(encoded) > maxSubmissionCursorLength { + return submissionCursor{}, errors.New("cursor length is invalid") + } + payload, err := base64.RawURLEncoding.DecodeString(encoded) + if err != nil || len(payload) > maxSubmissionCursorLength { + return submissionCursor{}, errors.New("cursor encoding is invalid") + } + var cursor submissionCursor + if err := json.Unmarshal(payload, &cursor); err != nil { + return submissionCursor{}, errors.New("cursor payload is invalid") + } + if cursor.CreatedAtUnixNano <= 0 || cursor.ID == "" || len(cursor.ID) > maxSubmissionIDLength || cursor.Scope == "" { + return submissionCursor{}, errors.New("cursor fields are invalid") + } + return cursor, nil +} + +func encodeSubmissionCursor(cursor submissionCursor) string { + payload, _ := json.Marshal(cursor) + return base64.RawURLEncoding.EncodeToString(payload) +} + +func submissionCursorScope(ownerUserID string) string { + digest := sha256.Sum256([]byte("submission-owner:" + ownerUserID)) + return base64.RawURLEncoding.EncodeToString(digest[:16]) +} + +// GetUserSubmissions is retained as a compatibility helper for internal +// callers. It is deliberately bounded to the public maximum and contains only +// summary fields; new code should consume ListSubmissions and its cursor. +func (s *SubmissionService) GetUserSubmissions(userID, problemID string) ([]models.Submission, error) { + page, err := s.ListSubmissions(userID, SubmissionListOptions{ProblemID: problemID, Limit: store.MaxSubmissionPageSize}) + if err != nil { + return nil, err + } + items := make([]models.Submission, 0, len(page.Items)) + for _, summary := range page.Items { + items = append(items, models.Submission{ + ID: summary.ID, + UserID: summary.UserID, + UserEmail: summary.UserEmail, + ProblemID: summary.ProblemID, + ProblemTitle: summary.ProblemTitle, + EvaluationRevision: summary.EvaluationRevision, + Language: summary.Language, + Status: summary.Status, + PassedTests: summary.PassedTests, + TotalTests: summary.TotalTests, + Score: summary.Score, + DurationMs: summary.DurationMs, + CreatedAt: summary.CreatedAt, + }) } + return items, nil +} - return result +// GetSubmission performs the ID and owner predicate together in the store. +// Empty ownerUserID is reserved for the authenticated admin detail view. +func (s *SubmissionService) GetSubmission(id, ownerUserID string) (models.Submission, bool, error) { + if id == "" || len(id) > maxSubmissionIDLength { + return models.Submission{}, false, nil + } + submission, found, err := s.store.LookupSubmissionByID(id, ownerUserID) + if err != nil { + return models.Submission{}, false, serviceError(ErrDependencyUnavailable, "submission storage is unavailable", err) + } + if !found { + return models.Submission{}, false, nil + } + problem, problemFound, err := lookupProblemForSubmission(s.store, submission.ProblemID) + if err != nil { + return models.Submission{}, false, serviceError(ErrDependencyUnavailable, "problem storage is unavailable", err) + } + return clientSafeSubmission(submission, problem, problemFound), true, nil } -func (s *SubmissionService) GetSubmission(id string) (models.Submission, bool) { - for _, submission := range s.store.GetAllSubmissions() { - if submission.ID == id { - return submission, true +func lookupProblemForSubmission(repository store.Repository, problemID string) (models.Problem, bool, error) { + return lookupProblemByID(repository, problemID) +} + +func lookupProblemByID(repository store.Repository, problemID string) (models.Problem, bool, error) { + if reader, ok := repository.(store.ErrorAwareProblemReader); ok { + return reader.LookupProblemByID(problemID) + } + problem, found := repository.GetProblemByID(problemID) + return problem, found, nil +} + +func clientSafeSubmission(submission models.Submission, problem models.Problem, problemFound bool) models.Submission { + publicIDs := make(map[string]struct{}) + // EvaluationRevision was introduced together with immutable problem + // snapshots and write-time hidden-test redaction. Older rows have no + // trustworthy visibility snapshot, so fail closed for every legacy result. + if submission.EvaluationRevision != "" && problemFound { + for _, testCase := range problem.TestCases { + if testCase.Visibility != models.TestHidden { + publicIDs[testCase.ID] = struct{}{} + } + } + } + results := append([]models.TestResult(nil), submission.TestResults...) + protectedOrdinal := 0 + for index := range results { + // Expected/actual values are catalog assertions, not durable candidate + // output. Always suppress them, even for a test ID that is public today: + // an administrator may have reused an ID that was hidden historically. + results[index].Expected = "" + results[index].Actual = "" + if _, publiclyKnown := publicIDs[results[index].TestCaseID]; publiclyKnown { + continue + } + protectedOrdinal++ + results[index].TestCaseID = fmt.Sprintf("protected-%d", protectedOrdinal) + results[index].Name = fmt.Sprintf("Protected test %d", protectedOrdinal) + if !results[index].Passed { + results[index].Error = "protected test failed" } } - return models.Submission{}, false + submission.TestResults = results + return submission } diff --git a/interviewhub-backend-go/internal/services/submission_service_test.go b/interviewhub-backend-go/internal/services/submission_service_test.go new file mode 100644 index 00000000..a8b6d184 --- /dev/null +++ b/interviewhub-backend-go/internal/services/submission_service_test.go @@ -0,0 +1,205 @@ +package services + +import ( + "encoding/json" + "errors" + "strings" + "testing" + "time" + + "github.com/interviewhub/backend/internal/models" + "github.com/interviewhub/backend/internal/store" +) + +type submissionReadSpy struct { + store.Repository + lookupID string + lookupOwnerID string + lookupCalls int + pageCalls int + pageError error + progressCalls int + progressError error +} + +func (s *submissionReadSpy) LookupSubmissionByID(id, ownerUserID string) (models.Submission, bool, error) { + s.lookupCalls++ + s.lookupID = id + s.lookupOwnerID = ownerUserID + return models.Submission{}, false, nil +} + +func (s *submissionReadSpy) LookupSubmissionPage(query store.SubmissionPageQuery) (store.SubmissionPageResult, error) { + s.pageCalls++ + if s.pageError != nil { + return store.SubmissionPageResult{}, s.pageError + } + return s.Repository.LookupSubmissionPage(query) +} + +func (s *submissionReadSpy) AggregateSubmissionProgress(userID string) (models.UserProgress, error) { + s.progressCalls++ + if s.progressError != nil { + return models.UserProgress{}, s.progressError + } + return s.Repository.AggregateSubmissionProgress(userID) +} + +func TestClientSafeSubmissionNeverReturnsHistoricalAssertionValues(t *testing.T) { + problem := models.Problem{TestCases: []models.TestCase{{ + ID: "reused-id", Name: "Currently public", Visibility: models.TestPublic, + }}} + submission := models.Submission{TestResults: []models.TestResult{ + {TestCaseID: "reused-id", Name: "Historically hidden", Expected: "old-secret", Actual: "candidate-secret"}, + {TestCaseID: "removed-id", Name: "Removed hidden", Expected: "other-secret", Actual: "other-actual", Error: "details"}, + }} + + safe := clientSafeSubmission(submission, problem, true) + if safe.TestResults[0].Expected != "" || safe.TestResults[0].Actual != "" || + safe.TestResults[0].TestCaseID == "reused-id" || safe.TestResults[0].Name == "Historically hidden" { + t.Fatalf("legacy row disclosed historical test metadata: %+v", safe.TestResults[0]) + } + if safe.TestResults[1].Expected != "" || safe.TestResults[1].Actual != "" || safe.TestResults[1].TestCaseID == "removed-id" { + t.Fatalf("removed test was not fail-closed: %+v", safe.TestResults[1]) + } +} + +func TestClientSafeSubmissionRetainsSnapshotEraPublicMetadata(t *testing.T) { + problem := models.Problem{TestCases: []models.TestCase{{ + ID: "public-id", Name: "Public test", Visibility: models.TestPublic, + }}} + submission := models.Submission{ + EvaluationRevision: "sha256:revision", + TestResults: []models.TestResult{{ + TestCaseID: "public-id", Name: "Public test", Expected: "secret", Actual: "candidate", + }}, + } + + safe := clientSafeSubmission(submission, problem, true) + if safe.TestResults[0].TestCaseID != "public-id" || safe.TestResults[0].Name != "Public test" { + t.Fatalf("snapshot-era public metadata was unnecessarily protected: %+v", safe.TestResults[0]) + } + if safe.TestResults[0].Expected != "" || safe.TestResults[0].Actual != "" { + t.Fatalf("public assertion values were disclosed: %+v", safe.TestResults[0]) + } +} + +func TestSubmissionHistoryUsesBoundedSummaryKeysetPages(t *testing.T) { + repository := store.NewMemoryStore() + service := NewSubmissionService(repository) + startedAt := time.Date(2026, time.August, 7, 12, 0, 0, 0, time.UTC) + rows := []models.Submission{ + {ID: "sub-05", UserID: "usr_cand_01", ProblemID: "parking-lot", ProblemTitle: "Parking", Status: models.SubmissionStatusAccepted, Language: models.LangGo, Files: []models.FileNode{{ID: "secret-source"}}, TestResults: []models.TestResult{{Expected: "secret"}}, ConsoleOutput: []string{"large output"}, CreatedAt: startedAt.Add(3 * time.Minute)}, + {ID: "sub-04", UserID: "usr_cand_01", ProblemID: "rate-limiter", ProblemTitle: "Limiter", Status: models.SubmissionStatusWrongAnswer, Language: models.LangPython, CreatedAt: startedAt.Add(2 * time.Minute)}, + {ID: "sub-03", UserID: "usr_cand_01", ProblemID: "parking-lot", ProblemTitle: "Parking", Status: models.SubmissionStatusWrongAnswer, Language: models.LangJava, CreatedAt: startedAt.Add(2 * time.Minute)}, + {ID: "sub-02", UserID: "usr_cand_01", ProblemID: "parking-lot", ProblemTitle: "Parking", Status: models.SubmissionStatusAccepted, Language: models.LangCpp, CreatedAt: startedAt.Add(time.Minute)}, + {ID: "sub-01", UserID: "usr_admin_01", ProblemID: "parking-lot", ProblemTitle: "Parking", Status: models.SubmissionStatusAccepted, Language: models.LangTypeScript, CreatedAt: startedAt}, + } + for _, row := range rows { + if _, err := repository.SaveSubmission(row); err != nil { + t.Fatalf("save %s: %v", row.ID, err) + } + } + + first, err := service.ListSubmissions("usr_cand_01", SubmissionListOptions{Limit: 2}) + if err != nil { + t.Fatalf("first page: %v", err) + } + if len(first.Items) != 2 || first.Items[0].ID != "sub-05" || first.Items[1].ID != "sub-04" || first.NextCursor == "" { + t.Fatalf("first page=%+v, want two newest candidate rows and a cursor", first) + } + payload, err := json.Marshal(first) + if err != nil { + t.Fatalf("marshal page: %v", err) + } + for _, heavyField := range []string{`"files"`, `"testResults"`, `"consoleOutput"`, "secret-source", "large output"} { + if strings.Contains(string(payload), heavyField) { + t.Fatalf("summary page leaked heavy field %q: %s", heavyField, payload) + } + } + + second, err := service.ListSubmissions("usr_cand_01", SubmissionListOptions{Limit: 2, Cursor: first.NextCursor}) + if err != nil { + t.Fatalf("second page: %v", err) + } + if len(second.Items) != 2 || second.Items[0].ID != "sub-03" || second.Items[1].ID != "sub-02" || second.NextCursor != "" { + t.Fatalf("second page=%+v, want remaining candidate rows", second) + } + + filtered, err := service.ListSubmissions("usr_cand_01", SubmissionListOptions{ProblemID: "parking-lot", Limit: 2}) + if err != nil { + t.Fatalf("filtered page: %v", err) + } + if len(filtered.Items) != 2 || filtered.Items[0].ID != "sub-05" || filtered.Items[1].ID != "sub-03" || filtered.NextCursor == "" { + t.Fatalf("filtered page=%+v, want SQL-equivalent problem filter", filtered) + } + if _, err := service.ListSubmissions("usr_cand_01", SubmissionListOptions{ProblemID: "rate-limiter", Limit: 2, Cursor: filtered.NextCursor}); !errors.Is(err, ErrInvalidInput) { + t.Fatalf("cursor reused across filters error=%v, want invalid input", err) + } + + admin, err := service.ListSubmissions("", SubmissionListOptions{Limit: store.MaxSubmissionPageSize}) + if err != nil { + t.Fatalf("admin page: %v", err) + } + if len(admin.Items) != len(rows) { + t.Fatalf("admin rows=%d, want %d", len(admin.Items), len(rows)) + } +} + +func TestSubmissionDetailPerformsOwnerAwareIDLookup(t *testing.T) { + spy := &submissionReadSpy{Repository: store.NewMemoryStore()} + service := NewSubmissionService(spy) + + if submission, found, err := service.GetSubmission("sub_foreign_or_missing", "usr_cand_01"); err != nil || found { + t.Fatalf("foreign lookup=(%+v,%v,%v), want not found", submission, found, err) + } + if spy.lookupCalls != 1 || spy.lookupID != "sub_foreign_or_missing" || spy.lookupOwnerID != "usr_cand_01" { + t.Fatalf("owner-aware lookup calls=%d id=%q owner=%q", spy.lookupCalls, spy.lookupID, spy.lookupOwnerID) + } + + tooLong := strings.Repeat("x", maxSubmissionIDLength+1) + if _, found, err := service.GetSubmission(tooLong, "usr_cand_01"); err != nil || found { + t.Fatalf("oversized ID lookup=(%v,%v), want not found", found, err) + } + if spy.lookupCalls != 1 { + t.Fatalf("oversized ID reached persistence; calls=%d", spy.lookupCalls) + } +} + +func TestSubmissionReadAndProgressFailuresAreClassifiedUnavailable(t *testing.T) { + injected := errors.New("database connection secret must not escape") + spy := &submissionReadSpy{ + Repository: store.NewMemoryStore(), + pageError: injected, + progressError: injected, + } + service := NewSubmissionService(spy) + if _, err := service.ListSubmissions("usr_cand_01", SubmissionListOptions{}); !errors.Is(err, ErrDependencyUnavailable) || !errors.Is(err, injected) { + t.Fatalf("list error=%v, want classified dependency failure", err) + } + userService := NewUserService(spy) + if _, err := userService.GetProgress("usr_cand_01"); !errors.Is(err, ErrDependencyUnavailable) || !errors.Is(err, injected) { + t.Fatalf("progress error=%v, want classified dependency failure", err) + } +} + +func TestSubmissionProgressAggregatesDistinctSolvedProblems(t *testing.T) { + repository := store.NewMemoryStore() + for _, submission := range []models.Submission{ + {ID: "accepted-1", UserID: "usr_cand_01", ProblemID: "parking-lot", Status: models.SubmissionStatusAccepted}, + {ID: "accepted-2", UserID: "usr_cand_01", ProblemID: "parking-lot", Status: models.SubmissionStatusAccepted}, + {ID: "accepted-3", UserID: "usr_cand_01", ProblemID: "rate-limiter", Status: models.SubmissionStatusAccepted}, + {ID: "wrong-1", UserID: "usr_cand_01", ProblemID: "lru-cache", Status: models.SubmissionStatusWrongAnswer}, + } { + if _, err := repository.SaveSubmission(submission); err != nil { + t.Fatalf("save progress row: %v", err) + } + } + progress, err := NewUserService(repository).GetProgress("usr_cand_01") + if err != nil { + t.Fatalf("get progress: %v", err) + } + if progress.SubmissionCount != 4 || progress.AcceptedCount != 3 || progress.SolvedCount != 2 || strings.Join(progress.SolvedProblemIDs, ",") != "parking-lot,rate-limiter" { + t.Fatalf("progress=%+v, want database-aggregatable distinct counts", progress) + } +} diff --git a/interviewhub-backend-go/internal/services/user_service.go b/interviewhub-backend-go/internal/services/user_service.go index 6c8e2019..8e3f8426 100644 --- a/interviewhub-backend-go/internal/services/user_service.go +++ b/interviewhub-backend-go/internal/services/user_service.go @@ -9,20 +9,24 @@ type UserService struct{ store store.Repository } func NewUserService(s store.Repository) *UserService { return &UserService{store: s} } -func (s *UserService) GetProfile(userID string) (models.User, bool) { - return s.store.GetUserByID(userID) +func (s *UserService) GetProfile(userID string) (models.User, error) { + user, found, err := lookupUserByID(s.store, userID) + if err != nil { + return models.User{}, serviceError(ErrDependencyUnavailable, "account storage is unavailable", err) + } + if !found { + return models.User{}, serviceError(ErrNotFound, "user not found", nil) + } + return user, nil } -func (s *UserService) GetProgress(userID string) models.UserProgress { - progress := models.UserProgress{} - solved := make(map[string]struct{}) - for _, submission := range s.store.GetSubmissionsByUserID(userID) { - progress.SubmissionCount++ - if submission.Status == "ACCEPTED" { - progress.AcceptedCount++ - solved[submission.ProblemID] = struct{}{} - } +func (s *UserService) GetProgress(userID string) (models.UserProgress, error) { + progress, err := s.store.AggregateSubmissionProgress(userID) + if err != nil { + return models.UserProgress{}, serviceError(ErrDependencyUnavailable, "submission progress storage is unavailable", err) + } + if progress.SolvedProblemIDs == nil { + progress.SolvedProblemIDs = make([]string, 0) } - progress.SolvedCount = len(solved) - return progress + return progress, nil } diff --git a/interviewhub-backend-go/internal/services/workspace_service.go b/interviewhub-backend-go/internal/services/workspace_service.go index 666ca4fe..c21f8531 100644 --- a/interviewhub-backend-go/internal/services/workspace_service.go +++ b/interviewhub-backend-go/internal/services/workspace_service.go @@ -2,21 +2,38 @@ package services import ( "errors" + "fmt" + "path/filepath" "strings" "github.com/interviewhub/backend/internal/models" "github.com/interviewhub/backend/internal/store" ) +const ( + maxWorkspaceNodes = 200 + maxWorkspaceContentBytes = 2 << 20 +) + type WorkspaceService struct{ store store.Repository } func NewWorkspaceService(s store.Repository) *WorkspaceService { return &WorkspaceService{store: s} } func (s *WorkspaceService) Get(userID, problemID string, language models.Language) (models.Workspace, error) { - if _, ok := s.store.GetProblemByID(problemID); !ok { - return models.Workspace{}, errors.New("problem not found") + if !isSupportedLanguage(language) { + return models.Workspace{}, serviceError(ErrInvalidInput, "unsupported language", nil) + } + problemExists, err := workspaceProblemExists(s.store, problemID) + if err != nil { + return models.Workspace{}, serviceError(ErrDependencyUnavailable, "problem storage is unavailable", err) + } + if !problemExists { + return models.Workspace{}, serviceError(ErrNotFound, "problem not found", nil) + } + workspace, ok, err := lookupWorkspace(s.store, userID, problemID, language) + if err != nil { + return models.Workspace{}, serviceError(ErrDependencyUnavailable, "workspace storage is unavailable", err) } - workspace, ok := s.store.GetWorkspace(userID, problemID, language) if !ok { return models.Workspace{UserID: userID, ProblemID: problemID, Language: language, Files: []models.FileNode{}}, nil } @@ -24,22 +41,133 @@ func (s *WorkspaceService) Get(userID, problemID string, language models.Languag } func (s *WorkspaceService) Save(userID, problemID string, workspace models.Workspace) (models.Workspace, error) { - if _, ok := s.store.GetProblemByID(problemID); !ok { - return models.Workspace{}, errors.New("problem not found") - } if !isSupportedLanguage(workspace.Language) { - return models.Workspace{}, errors.New("unsupported language") + return models.Workspace{}, serviceError(ErrInvalidInput, "unsupported language", nil) + } + if err := validateWorkspaceFiles(workspace.Files, workspace.Language, workspace.ActiveFileID); err != nil { + return models.Workspace{}, serviceError(ErrInvalidInput, err.Error(), nil) } - if len(workspace.Files) > 200 { - return models.Workspace{}, errors.New("workspace cannot contain more than 200 files") + problemExists, err := workspaceProblemExists(s.store, problemID) + if err != nil { + return models.Workspace{}, serviceError(ErrDependencyUnavailable, "problem storage is unavailable", err) + } + if !problemExists { + return models.Workspace{}, serviceError(ErrNotFound, "problem not found", nil) } workspace.UserID = userID workspace.ProblemID = problemID - return s.store.SaveWorkspace(workspace), nil + saved, err := s.store.SaveWorkspace(workspace) + if err != nil { + if errors.Is(err, store.ErrConflict) { + return models.Workspace{}, serviceError(ErrConflict, "workspace changed after it was loaded", err) + } + return models.Workspace{}, serviceError(ErrDependencyUnavailable, "workspace storage is unavailable", err) + } + return saved, nil } -func (s *WorkspaceService) Delete(userID, problemID string, language models.Language) bool { - return s.store.DeleteWorkspace(userID, problemID, language) +func (s *WorkspaceService) Delete(userID, problemID string, language models.Language) error { + if !isSupportedLanguage(language) { + return serviceError(ErrInvalidInput, "unsupported language", nil) + } + deleted, err := s.store.DeleteWorkspace(userID, problemID, language) + if err != nil { + return serviceError(ErrDependencyUnavailable, "workspace storage is unavailable", err) + } + if !deleted { + return serviceError(ErrNotFound, "workspace not found", nil) + } + return nil +} + +func workspaceProblemExists(repository store.Repository, problemID string) (bool, error) { + if checker, ok := repository.(store.ErrorAwareProblemExistence); ok { + return checker.ProblemExists(problemID) + } + _, found := repository.GetProblemByID(problemID) + return found, nil +} + +func lookupWorkspace(repository store.Repository, userID, problemID string, language models.Language) (models.Workspace, bool, error) { + if lookup, ok := repository.(store.ErrorAwareWorkspaceLookup); ok { + return lookup.LookupWorkspace(userID, problemID, language) + } + workspace, found := repository.GetWorkspace(userID, problemID, language) + return workspace, found, nil +} + +func validateWorkspaceFiles(nodes []models.FileNode, language models.Language, activeFileID string) error { + nodeCount := 0 + contentBytes := 0 + activeFileFound := activeFileID == "" + seenPaths := make(map[string]struct{}) + seenIDs := make(map[string]struct{}) + + var walk func([]models.FileNode) error + walk = func(items []models.FileNode) error { + for _, node := range items { + nodeCount++ + if nodeCount > maxWorkspaceNodes { + return fmt.Errorf("workspace cannot contain more than %d files and folders", maxWorkspaceNodes) + } + name := strings.TrimSpace(node.Name) + if name == "" || name == "." || name == ".." || filepath.Base(name) != name || strings.ContainsAny(name, `/\`) { + return fmt.Errorf("invalid workspace node name %q", node.Name) + } + cleanPath, err := cleanSandboxRelativePath(node.Path) + if err != nil { + return err + } + if filepath.Base(cleanPath) != name { + return fmt.Errorf("workspace path %q does not match node name %q", node.Path, node.Name) + } + if _, exists := seenPaths[cleanPath]; exists { + return fmt.Errorf("duplicate workspace path %q", node.Path) + } + seenPaths[cleanPath] = struct{}{} + if strings.TrimSpace(node.ID) == "" { + return errors.New("workspace node id is required") + } + if _, exists := seenIDs[node.ID]; exists { + return fmt.Errorf("duplicate workspace node id %q", node.ID) + } + seenIDs[node.ID] = struct{}{} + + if node.IsDirectory { + if node.Content != "" { + return fmt.Errorf("workspace directory %q cannot contain source content", node.Path) + } + if node.ID == activeFileID { + return errors.New("activeFileId must refer to a source file") + } + } else { + if len(node.Children) > 0 { + return fmt.Errorf("workspace file %q cannot contain child nodes", node.Path) + } + if node.Language != "" && node.Language != language { + return fmt.Errorf("workspace file %q has a different language", node.Path) + } + contentBytes += len(node.Content) + if contentBytes > maxWorkspaceContentBytes { + return fmt.Errorf("workspace source exceeds %d bytes", maxWorkspaceContentBytes) + } + if node.ID == activeFileID { + activeFileFound = true + } + } + if err := walk(node.Children); err != nil { + return err + } + } + return nil + } + if err := walk(nodes); err != nil { + return err + } + if !activeFileFound { + return errors.New("activeFileId does not reference a workspace file") + } + return nil } func isSupportedLanguage(language models.Language) bool { diff --git a/interviewhub-backend-go/internal/services/workspace_service_test.go b/interviewhub-backend-go/internal/services/workspace_service_test.go new file mode 100644 index 00000000..59b169ea --- /dev/null +++ b/interviewhub-backend-go/internal/services/workspace_service_test.go @@ -0,0 +1,98 @@ +package services + +import ( + "fmt" + "strings" + "testing" + + "github.com/interviewhub/backend/internal/models" + "github.com/interviewhub/backend/internal/store" +) + +func validWorkspace() models.Workspace { + return models.Workspace{ + Language: models.LangGo, + ActiveFileID: "main", + Files: []models.FileNode{{ + ID: "src", + Name: "src", + Path: "src", + IsDirectory: true, + Children: []models.FileNode{{ + ID: "main", + Name: "main.go", + Path: "src/main.go", + Language: models.LangGo, + Content: "package main", + }}, + }}, + } +} + +func TestWorkspaceSaveValidatesNestedTreeAndOwnership(t *testing.T) { + repository := store.NewMemoryStore() + service := NewWorkspaceService(repository) + workspace := validWorkspace() + workspace.UserID = "attacker-selected-user" + workspace.ProblemID = "attacker-selected-problem" + + saved, err := service.Save("usr_cand_01", "parking-lot", workspace) + if err != nil { + t.Fatalf("save valid workspace: %v", err) + } + if saved.UserID != "usr_cand_01" || saved.ProblemID != "parking-lot" { + t.Fatalf("service did not enforce ownership/path scope: %+v", saved) + } + loaded, err := service.Get("usr_cand_01", "parking-lot", models.LangGo) + if err != nil || loaded.ActiveFileID != "main" { + t.Fatalf("load workspace=(%+v,%v)", loaded, err) + } +} + +func TestWorkspaceSaveRejectsUnsafeOrOversizedTrees(t *testing.T) { + repository := store.NewMemoryStore() + service := NewWorkspaceService(repository) + + tooManyChildren := make([]models.FileNode, 0, maxWorkspaceNodes) + for index := 0; index < maxWorkspaceNodes; index++ { + name := fmt.Sprintf("file-%03d.go", index) + tooManyChildren = append(tooManyChildren, models.FileNode{ID: name, Name: name, Path: "src/" + name, Language: models.LangGo}) + } + + cases := []struct { + name string + workspace models.Workspace + }{ + {name: "traversal", workspace: func() models.Workspace { + workspace := validWorkspace() + workspace.Files[0].Children[0].Path = "../main.go" + return workspace + }()}, + {name: "directory content", workspace: func() models.Workspace { + workspace := validWorkspace() + workspace.Files[0].Content = "not allowed" + return workspace + }()}, + {name: "unknown active file", workspace: func() models.Workspace { + workspace := validWorkspace() + workspace.ActiveFileID = "missing" + return workspace + }()}, + {name: "aggregate content", workspace: models.Workspace{Language: models.LangGo, Files: []models.FileNode{{ID: "large", Name: "large.go", Path: "large.go", Language: models.LangGo, Content: strings.Repeat("x", maxWorkspaceContentBytes+1)}}}}, + {name: "deep file count", workspace: models.Workspace{Language: models.LangGo, Files: []models.FileNode{{ID: "src", Name: "src", Path: "src", IsDirectory: true, Children: tooManyChildren}}}}, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + if _, err := service.Save("usr_cand_01", "parking-lot", testCase.workspace); err == nil { + t.Fatal("expected workspace validation error") + } + }) + } +} + +func TestWorkspaceGetRejectsUnsupportedLanguage(t *testing.T) { + service := NewWorkspaceService(store.NewMemoryStore()) + if _, err := service.Get("usr_cand_01", "parking-lot", "ruby"); err == nil { + t.Fatal("expected unsupported language error") + } +} diff --git a/interviewhub-backend-go/internal/store/auth_retention.go b/interviewhub-backend-go/internal/store/auth_retention.go new file mode 100644 index 00000000..b5bd1b4f --- /dev/null +++ b/interviewhub-backend-go/internal/store/auth_retention.go @@ -0,0 +1,21 @@ +package store + +import "time" + +const ( + // Keep expired credentials briefly for operational diagnosis while bounding + // table growth. Active refresh tokens and every unexpired revoked token stay + // available for family-replay detection through their full validity window. + authArtifactExpiryGrace = 24 * time.Hour + authArtifactCleanupBatch = 100 + authArtifactCleanupTimeout = 2 * time.Second + + // Every rotation inherits the original family's expiry, so a family cannot + // be kept alive indefinitely. The generation ceiling bounds retained replay + // markers even if a client rotates as quickly as admission control permits. + // A continuously active 30-day session can legitimately rotate about 2,880 + // times at a 15-minute access-token cadence. 4,096 keeps ample headroom while + // still imposing a firm per-family storage bound. + refreshTokenFamilyMaxGenerations = 4096 + refreshTokenAdvisoryNamespace = 0x4a4d4301 +) diff --git a/interviewhub-backend-go/internal/store/auth_retention_postgres_test.go b/interviewhub-backend-go/internal/store/auth_retention_postgres_test.go new file mode 100644 index 00000000..bdb385c9 --- /dev/null +++ b/interviewhub-backend-go/internal/store/auth_retention_postgres_test.go @@ -0,0 +1,149 @@ +package store + +import ( + "context" + "database/sql" + "database/sql/driver" + "errors" + "fmt" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/interviewhub/backend/internal/models" +) + +type authArtifactExecDriver struct { + exec func(context.Context, string, []driver.NamedValue) (driver.Result, error) +} + +func (d *authArtifactExecDriver) Open(string) (driver.Conn, error) { + return &authArtifactExecConnection{exec: d.exec}, nil +} + +type authArtifactExecConnection struct { + exec func(context.Context, string, []driver.NamedValue) (driver.Result, error) +} + +func (c *authArtifactExecConnection) Prepare(string) (driver.Stmt, error) { + return nil, errors.New("prepare is not supported by the auth artifact test driver") +} + +func (c *authArtifactExecConnection) Close() error { return nil } + +func (c *authArtifactExecConnection) Begin() (driver.Tx, error) { + return nil, errors.New("transactions are not supported by the auth artifact test driver") +} + +func (c *authArtifactExecConnection) ExecContext(ctx context.Context, query string, args []driver.NamedValue) (driver.Result, error) { + return c.exec(ctx, query, args) +} + +var authArtifactDriverSequence atomic.Uint64 + +func newAuthArtifactTestStore( + t *testing.T, + exec func(context.Context, string, []driver.NamedValue) (driver.Result, error), +) *PostgresStore { + t.Helper() + driverName := fmt.Sprintf("judgemycode-auth-artifact-test-%d", authArtifactDriverSequence.Add(1)) + sql.Register(driverName, &authArtifactExecDriver{exec: exec}) + db, err := sql.Open(driverName, "") + if err != nil { + t.Fatalf("open auth artifact test database: %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + return &PostgresStore{db: db} +} + +func TestAuthArtifactIssuanceIgnoresCleanupFailureAfterInsert(t *testing.T) { + cleanupFailure := errors.New("cleanup unavailable") + tests := []struct { + name string + insertSQL string + issue func(*PostgresStore) error + }{ + { + name: "refresh token", + insertSQL: "INSERT INTO refresh_tokens", + issue: func(repository *PostgresStore) error { + now := time.Now() + return repository.SaveRefreshToken(models.RefreshToken{ + ID: "refresh-id", + UserID: "user-id", + TokenHash: "refresh-hash", + CreatedAt: now, + ExpiresAt: now.Add(time.Hour), + }) + }, + }, + { + name: "auth code", + insertSQL: "INSERT INTO auth_codes", + issue: func(repository *PostgresStore) error { + return repository.SaveAuthCode("code-hash", "user-id", time.Now().Add(time.Minute)) + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + queries := make([]string, 0, 2) + repository := newAuthArtifactTestStore(t, func(_ context.Context, query string, _ []driver.NamedValue) (driver.Result, error) { + queries = append(queries, query) + if strings.Contains(query, test.insertSQL) { + return driver.RowsAffected(1), nil + } + return nil, cleanupFailure + }) + + if err := test.issue(repository); err != nil { + t.Fatalf("issuance failed because cleanup failed: %v", err) + } + if len(queries) != 2 { + t.Fatalf("executed %d statements, want insert followed by one failed cleanup: %v", len(queries), queries) + } + if !strings.Contains(queries[0], test.insertSQL) { + t.Fatalf("first statement was not issuance insert: %q", queries[0]) + } + if !strings.Contains(queries[1], "WITH stale AS") { + t.Fatalf("second statement was not retention cleanup: %q", queries[1]) + } + }) + } +} + +func TestAuthArtifactCleanupIsBoundedAndSkipsLockedRows(t *testing.T) { + type observedCall struct { + query string + deadline time.Time + hasDeadline bool + } + calls := make([]observedCall, 0, 2) + repository := newAuthArtifactTestStore(t, func(ctx context.Context, query string, _ []driver.NamedValue) (driver.Result, error) { + deadline, hasDeadline := ctx.Deadline() + calls = append(calls, observedCall{query: query, deadline: deadline, hasDeadline: hasDeadline}) + return driver.RowsAffected(1), nil + }) + + started := time.Now() + if err := repository.cleanupExpiredAuthArtifacts(started); err != nil { + t.Fatalf("cleanup expired auth artifacts: %v", err) + } + if len(calls) != 2 { + t.Fatalf("cleanup statements=%d, want 2", len(calls)) + } + for index, call := range calls { + if !strings.Contains(call.query, "FOR UPDATE SKIP LOCKED") { + t.Errorf("cleanup statement %d does not skip locked rows: %q", index+1, call.query) + } + if !call.hasDeadline { + t.Errorf("cleanup statement %d has no context deadline", index+1) + continue + } + if duration := call.deadline.Sub(started); duration <= 0 || duration > authArtifactCleanupTimeout+time.Second { + t.Errorf("cleanup statement %d deadline=%s from start, want at most %s", index+1, duration, authArtifactCleanupTimeout) + } + } +} diff --git a/interviewhub-backend-go/internal/store/migrations/005_problem_test_case_identity.sql b/interviewhub-backend-go/internal/store/migrations/005_problem_test_case_identity.sql new file mode 100644 index 00000000..2b6e4ae6 --- /dev/null +++ b/interviewhub-backend-go/internal/store/migrations/005_problem_test_case_identity.sql @@ -0,0 +1,8 @@ +-- Test-case identifiers are scoped to their owning problem. Admin-created +-- catalogs commonly use IDs such as `tc-1`; a global primary key made the +-- second problem with that perfectly valid local ID fail to save. +ALTER TABLE problem_test_cases + DROP CONSTRAINT IF EXISTS problem_test_cases_pkey; + +ALTER TABLE problem_test_cases + ADD CONSTRAINT problem_test_cases_pkey PRIMARY KEY (problem_id, id); diff --git a/interviewhub-backend-go/internal/store/migrations/006_workspace_revisions.sql b/interviewhub-backend-go/internal/store/migrations/006_workspace_revisions.sql new file mode 100644 index 00000000..c2e9403f --- /dev/null +++ b/interviewhub-backend-go/internal/store/migrations/006_workspace_revisions.sql @@ -0,0 +1,7 @@ +-- Optimistic workspace revisions prevent one browser or device from silently +-- overwriting a draft that changed after it was loaded. +ALTER TABLE workspaces + ADD COLUMN IF NOT EXISTS revision BIGINT NOT NULL DEFAULT 1; + +ALTER TABLE workspaces + ADD CONSTRAINT workspaces_revision_positive CHECK (revision > 0); diff --git a/interviewhub-backend-go/internal/store/migrations/007_auth_artifact_retention.sql b/interviewhub-backend-go/internal/store/migrations/007_auth_artifact_retention.sql new file mode 100644 index 00000000..92db791b --- /dev/null +++ b/interviewhub-backend-go/internal/store/migrations/007_auth_artifact_retention.sql @@ -0,0 +1,4 @@ +-- Bounded opportunistic cleanup orders stale refresh tokens by expiration. +-- Auth codes already have an expiration index from migration 004. +CREATE INDEX IF NOT EXISTS refresh_tokens_expires_idx + ON refresh_tokens (expires_at); diff --git a/interviewhub-backend-go/internal/store/migrations/008_oauth_identities.sql b/interviewhub-backend-go/internal/store/migrations/008_oauth_identities.sql new file mode 100644 index 00000000..0193b5f1 --- /dev/null +++ b/interviewhub-backend-go/internal/store/migrations/008_oauth_identities.sql @@ -0,0 +1,15 @@ +-- OAuth accounts are bound to the provider's immutable subject identifier. +-- Email remains profile data and must never be the long-term login key. +CREATE TABLE IF NOT EXISTS oauth_identities ( + provider TEXT NOT NULL CHECK (provider IN ('google', 'github', 'linkedin')), + subject TEXT NOT NULL, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + provider_email TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (provider, subject), + UNIQUE (provider, user_id) +); + +CREATE INDEX IF NOT EXISTS oauth_identities_user_idx + ON oauth_identities (user_id); diff --git a/interviewhub-backend-go/internal/store/migrations/009_history_and_delete_safety.sql b/interviewhub-backend-go/internal/store/migrations/009_history_and_delete_safety.sql new file mode 100644 index 00000000..ea61c2f9 --- /dev/null +++ b/interviewhub-backend-go/internal/store/migrations/009_history_and_delete_safety.sql @@ -0,0 +1,37 @@ +-- Candidate workspaces must never disappear as a side effect of deleting a +-- catalog problem. The application returns a typed conflict before this +-- constraint is reached; RESTRICT is the database-level final guard. +ALTER TABLE workspaces + DROP CONSTRAINT IF EXISTS workspaces_problem_id_fkey; + +ALTER TABLE workspaces + ADD CONSTRAINT workspaces_problem_id_fkey + FOREIGN KEY (problem_id) REFERENCES problems(id) ON DELETE RESTRICT; + +-- A submission is an immutable historical evaluation snapshot. Retain its +-- problem_id as a logical identifier even if an unreferenced catalog entry is +-- removed between queue admission and terminal result persistence. +ALTER TABLE submissions + DROP CONSTRAINT IF EXISTS submissions_problem_id_fkey; + +-- Old rows may contain assertion values written before response redaction was +-- made fail-closed. They are not useful submission history and should not stay +-- recoverable from the JSON payload at rest. +UPDATE submissions AS submission +SET data = jsonb_set( + submission.data, + '{testResults}', + COALESCE( + ( + SELECT jsonb_agg( + test_result.value - 'expected' - 'actual' + ORDER BY test_result.ordinality + ) + FROM jsonb_array_elements(submission.data->'testResults') + WITH ORDINALITY AS test_result(value, ordinality) + ), + '[]'::JSONB + ), + FALSE +) +WHERE jsonb_typeof(submission.data->'testResults') = 'array'; diff --git a/interviewhub-backend-go/internal/store/migrations/010_submission_read_model.sql b/interviewhub-backend-go/internal/store/migrations/010_submission_read_model.sql new file mode 100644 index 00000000..77767a63 --- /dev/null +++ b/interviewhub-backend-go/internal/store/migrations/010_submission_read_model.sql @@ -0,0 +1,43 @@ +-- Submission history list/progress reads use scalar projections so PostgreSQL +-- never needs to send the source tree and verbose execution output stored in +-- data JSONB. Generated columns stay correct for old and new writers because +-- data remains the immutable source of truth. +ALTER TABLE submissions + ADD COLUMN IF NOT EXISTS user_email TEXT + GENERATED ALWAYS AS (COALESCE(data->>'userEmail', '')) STORED, + ADD COLUMN IF NOT EXISTS problem_title TEXT + GENERATED ALWAYS AS (COALESCE(data->>'problemTitle', '')) STORED, + ADD COLUMN IF NOT EXISTS evaluation_revision TEXT + GENERATED ALWAYS AS (COALESCE(data->>'evaluationRevision', '')) STORED, + ADD COLUMN IF NOT EXISTS language TEXT + GENERATED ALWAYS AS (COALESCE(data->>'language', '')) STORED, + ADD COLUMN IF NOT EXISTS status TEXT + GENERATED ALWAYS AS (COALESCE(data->>'status', '')) STORED, + ADD COLUMN IF NOT EXISTS passed_tests INTEGER + GENERATED ALWAYS AS (COALESCE((data->>'passedTests')::INTEGER, 0)) STORED, + ADD COLUMN IF NOT EXISTS total_tests INTEGER + GENERATED ALWAYS AS (COALESCE((data->>'totalTests')::INTEGER, 0)) STORED, + ADD COLUMN IF NOT EXISTS score INTEGER + GENERATED ALWAYS AS (COALESCE((data->>'score')::INTEGER, 0)) STORED, + ADD COLUMN IF NOT EXISTS duration_ms BIGINT + GENERATED ALWAYS AS (COALESCE((data->>'durationMs')::BIGINT, 0)) STORED; + +-- Candidate detail lookup proves ownership while using a bounded index path. +CREATE INDEX IF NOT EXISTS submissions_owner_id_idx + ON submissions(user_id, id); + +-- Keyset pagination always orders by the unique (created_at, id) tuple. The +-- following indexes cover candidate/admin views with and without a problem +-- filter; no query needs an OFFSET or an unbounded scan/sort. +CREATE INDEX IF NOT EXISTS submissions_user_created_id_idx + ON submissions(user_id, created_at DESC, id DESC); +CREATE INDEX IF NOT EXISTS submissions_user_problem_created_id_idx + ON submissions(user_id, problem_id, created_at DESC, id DESC); +CREATE INDEX IF NOT EXISTS submissions_created_id_idx + ON submissions(created_at DESC, id DESC); +CREATE INDEX IF NOT EXISTS submissions_problem_created_id_idx + ON submissions(problem_id, created_at DESC, id DESC); + +-- Progress is grouped by owner and accepted problem without decoding data. +CREATE INDEX IF NOT EXISTS submissions_user_status_problem_idx + ON submissions(user_id, status, problem_id); diff --git a/interviewhub-backend-go/internal/store/postgres.go b/interviewhub-backend-go/internal/store/postgres.go index 0894d67c..6a492ae3 100644 --- a/interviewhub-backend-go/internal/store/postgres.go +++ b/interviewhub-backend-go/internal/store/postgres.go @@ -6,10 +6,13 @@ import ( "embed" "encoding/json" "errors" + "fmt" + "strings" "time" "github.com/interviewhub/backend/internal/config" "github.com/interviewhub/backend/internal/models" + "github.com/jackc/pgx/v5/pgconn" _ "github.com/jackc/pgx/v5/stdlib" ) @@ -21,6 +24,17 @@ var migrationFS embed.FS // paths are indexed by first-class columns. type PostgresStore struct{ db *sql.DB } +var ( + _ Repository = (*PostgresStore)(nil) + _ ErrorAwareUserLookup = (*PostgresStore)(nil) + _ ErrorAwareProblemExistence = (*PostgresStore)(nil) + _ ErrorAwareProblemReader = (*PostgresStore)(nil) + _ ErrorAwareWorkspaceLookup = (*PostgresStore)(nil) + _ ErrorAwareAuthCodeConsumer = (*PostgresStore)(nil) + _ ErrorAwareRefreshTokenRevoker = (*PostgresStore)(nil) + _ OAuthIdentityRepository = (*PostgresStore)(nil) +) + // OpenPostgres connects to DATABASE_URL without migrating or seeding. // Use Seed(...) afterwards when you need schema + catalog population. func OpenPostgres(ctx context.Context, databaseURL string) (*PostgresStore, error) { @@ -42,140 +56,595 @@ func OpenPostgres(ctx context.Context, databaseURL string) (*PostgresStore, erro } func NewPostgres(ctx context.Context, cfg *config.Config) (*PostgresStore, error) { - store, err := OpenPostgres(ctx, cfg.DatabaseURL) - if err != nil { - return nil, err - } - if _, err := store.Seed(ctx, DefaultSeedOptions()); err != nil { - _ = store.Close() - return nil, err - } - return store, nil + // Migrations and demo data are deployment concerns, not API process startup + // concerns. Run cmd/seed (or the deployment migration job) before booting the + // server so replicas never race migrations or create known demo accounts. + return OpenPostgres(ctx, cfg.DatabaseURL) } func (s *PostgresStore) Close() error { return s.db.Close() } +// Ping verifies that PostgreSQL can serve requests. It is intentionally kept +// separate from liveness so a transient database outage removes this replica +// from traffic without causing a restart loop. +func (s *PostgresStore) Ping(ctx context.Context) error { + return s.db.PingContext(ctx) +} + func (s *PostgresStore) GetUserByID(id string) (models.User, bool) { - return s.getUser("SELECT id, name, email, COALESCE(password_hash, ''), role, COALESCE(avatar, ''), COALESCE(auth_provider, 'local'), created_at FROM users WHERE id = $1", id) + user, ok, _ := s.LookupUserByID(id) + return user, ok } func (s *PostgresStore) GetUserByEmail(email string) (models.User, bool) { - return s.getUser("SELECT id, name, email, COALESCE(password_hash, ''), role, COALESCE(avatar, ''), COALESCE(auth_provider, 'local'), created_at FROM users WHERE LOWER(email) = LOWER($1)", email) + user, ok, _ := s.LookupUserByEmail(email) + return user, ok +} + +func (s *PostgresStore) LookupUserByID(id string) (models.User, bool, error) { + return s.lookupUser("SELECT id, name, email, COALESCE(password_hash, ''), role, COALESCE(avatar, ''), COALESCE(auth_provider, 'local'), created_at FROM users WHERE id = $1", id) +} + +func (s *PostgresStore) LookupUserByEmail(email string) (models.User, bool, error) { + return s.lookupUser("SELECT id, name, email, COALESCE(password_hash, ''), role, COALESCE(avatar, ''), COALESCE(auth_provider, 'local'), created_at FROM users WHERE LOWER(email) = LOWER($1)", email) } -func (s *PostgresStore) getUser(query string, arg string) (models.User, bool) { +func (s *PostgresStore) lookupUser(query string, args ...any) (models.User, bool, error) { + ctx, cancel := postgresRuntimeContext() + defer cancel() + var user models.User - err := s.db.QueryRow(query, arg).Scan(&user.ID, &user.Name, &user.Email, &user.PasswordHash, &user.Role, &user.Avatar, &user.AuthProvider, &user.CreatedAt) - return user, err == nil + err := s.db.QueryRowContext(ctx, query, args...).Scan(&user.ID, &user.Name, &user.Email, &user.PasswordHash, &user.Role, &user.Avatar, &user.AuthProvider, &user.CreatedAt) + if errors.Is(err, sql.ErrNoRows) { + return models.User{}, false, nil + } + if err != nil { + return models.User{}, false, fmt.Errorf("lookup user: %w", err) + } + return user, true, nil } -func (s *PostgresStore) CreateUser(user models.User) models.User { +func (s *PostgresStore) CreateUser(user models.User) (models.User, error) { + ctx, cancel := postgresRuntimeContext() + defer cancel() + if user.AuthProvider == "" { user.AuthProvider = "local" } - _, err := s.db.Exec(`INSERT INTO users (id, name, email, password_hash, role, avatar, auth_provider, created_at) + _, err := s.db.ExecContext(ctx, `INSERT INTO users (id, name, email, password_hash, role, avatar, auth_provider, created_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, avatar = EXCLUDED.avatar, auth_provider = EXCLUDED.auth_provider`, user.ID, user.Name, user.Email, user.PasswordHash, user.Role, user.Avatar, user.AuthProvider, user.CreatedAt) if err != nil { - return models.User{} + var postgresError *pgconn.PgError + if errors.As(err, &postgresError) && postgresError.Code == "23505" { + return models.User{}, fmt.Errorf("%w: user already exists", ErrConflict) + } + return models.User{}, fmt.Errorf("create user: %w", err) } - return user + return user, nil +} + +func (s *PostgresStore) LookupOAuthIdentity(provider, subject string) (models.User, bool, error) { + return s.lookupUser(`SELECT u.id, u.name, u.email, COALESCE(u.password_hash, ''), u.role, + COALESCE(u.avatar, ''), COALESCE(u.auth_provider, 'local'), u.created_at + FROM oauth_identities i + JOIN users u ON u.id = i.user_id + WHERE i.provider = $1 AND i.subject = $2`, provider, subject) } -func (s *PostgresStore) GetAllSubmissions() []models.Submission { - return s.getSubmissions("SELECT data FROM submissions ORDER BY created_at DESC") +func (s *PostgresStore) LinkOAuthIdentity(provider, subject, userID, providerEmail string) error { + ctx, cancel := postgresRuntimeContext() + defer cancel() + + _, err := s.db.ExecContext(ctx, `INSERT INTO oauth_identities (provider, subject, user_id, provider_email) + VALUES ($1, $2, $3, $4)`, provider, subject, userID, providerEmail) + if err != nil { + var postgresError *pgconn.PgError + if errors.As(err, &postgresError) && postgresError.Code == "23505" { + return fmt.Errorf("%w: OAuth identity is already linked", ErrConflict) + } + return fmt.Errorf("link OAuth identity: %w", err) + } + return nil } -func (s *PostgresStore) GetSubmissionsByUserID(userID string) []models.Submission { - return s.getSubmissions("SELECT data FROM submissions WHERE user_id = $1 ORDER BY created_at DESC", userID) +// CountUnboundOAuthUsers is a rollout preflight for installations that created +// social accounts before stable provider-subject identities were introduced. +// Those rows require explicit operator-assisted binding; email-only automatic +// linking would create an account-takeover window. +func (s *PostgresStore) CountUnboundOAuthUsers(ctx context.Context, enabledProviders []string) (int, error) { + enabled := make(map[string]bool, len(enabledProviders)) + for _, provider := range enabledProviders { + enabled[provider] = true + } + var count int + err := s.db.QueryRowContext(ctx, ` + SELECT COUNT(*) + FROM users AS candidate + WHERE ((LOWER(candidate.auth_provider) = 'google' AND $1) + OR (LOWER(candidate.auth_provider) = 'github' AND $2) + OR (LOWER(candidate.auth_provider) = 'linkedin' AND $3)) + AND NOT EXISTS ( + SELECT 1 + FROM oauth_identities AS identity + WHERE identity.user_id = candidate.id + AND identity.provider = LOWER(candidate.auth_provider) + )`, enabled["google"], enabled["github"], enabled["linkedin"]).Scan(&count) + if err != nil { + return 0, fmt.Errorf("count unbound OAuth users: %w", err) + } + return count, nil } -func (s *PostgresStore) getSubmissions(query string, args ...any) []models.Submission { - rows, err := s.db.Query(query, args...) +func (s *PostgresStore) CreateOAuthUserWithIdentity(user models.User, provider, subject, providerEmail string) (models.User, error) { + ctx, cancel := postgresRuntimeContext() + defer cancel() + + tx, err := s.db.BeginTx(ctx, nil) if err != nil { - return nil + return models.User{}, fmt.Errorf("begin OAuth user transaction: %w", err) + } + defer func() { _ = tx.Rollback() }() + if _, err := tx.ExecContext(ctx, `INSERT INTO users (id, name, email, password_hash, role, avatar, auth_provider, created_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, + user.ID, user.Name, user.Email, user.PasswordHash, user.Role, user.Avatar, provider, user.CreatedAt); err != nil { + var postgresError *pgconn.PgError + if errors.As(err, &postgresError) && postgresError.Code == "23505" { + return models.User{}, fmt.Errorf("%w: OAuth user already exists", ErrConflict) + } + return models.User{}, fmt.Errorf("create OAuth user: %w", err) + } + if _, err := tx.ExecContext(ctx, `INSERT INTO oauth_identities (provider, subject, user_id, provider_email) + VALUES ($1, $2, $3, $4)`, provider, subject, user.ID, providerEmail); err != nil { + var postgresError *pgconn.PgError + if errors.As(err, &postgresError) && postgresError.Code == "23505" { + return models.User{}, fmt.Errorf("%w: OAuth identity already exists", ErrConflict) + } + return models.User{}, fmt.Errorf("create OAuth identity: %w", err) + } + if err := tx.Commit(); err != nil { + return models.User{}, fmt.Errorf("commit OAuth user transaction: %w", err) + } + user.AuthProvider = provider + return user, nil +} + +func (s *PostgresStore) LookupSubmissionPage(pageQuery SubmissionPageQuery) (SubmissionPageResult, error) { + ctx, cancel := postgresRuntimeContext() + defer cancel() + + limit := pageQuery.Limit + if limit <= 0 { + limit = DefaultSubmissionPageSize + } + if limit > MaxSubmissionPageSize { + limit = MaxSubmissionPageSize + } + + query := `SELECT id, user_id, user_email, problem_id, problem_title, + evaluation_revision, language, status, passed_tests, total_tests, + score, duration_ms, created_at + FROM submissions` + clauses := make([]string, 0, 3) + args := make([]any, 0, 5) + addPredicate := func(format string, value any) { + args = append(args, value) + clauses = append(clauses, fmt.Sprintf(format, len(args))) + } + if pageQuery.OwnerUserID != "" { + addPredicate("user_id = $%d", pageQuery.OwnerUserID) + } + if pageQuery.ProblemID != "" { + addPredicate("problem_id = $%d", pageQuery.ProblemID) + } + if !pageQuery.BeforeCreatedAt.IsZero() { + args = append(args, pageQuery.BeforeCreatedAt, pageQuery.BeforeID) + clauses = append(clauses, fmt.Sprintf("(created_at, id) < ($%d, $%d)", len(args)-1, len(args))) + } + if len(clauses) > 0 { + query += " WHERE " + strings.Join(clauses, " AND ") + } + args = append(args, limit+1) + query += fmt.Sprintf(" ORDER BY created_at DESC, id DESC LIMIT $%d", len(args)) + + rows, err := s.db.QueryContext(ctx, query, args...) + if err != nil { + return SubmissionPageResult{}, fmt.Errorf("query submission page: %w", err) } defer rows.Close() - items := make([]models.Submission, 0) + + items := make([]models.SubmissionSummary, 0, limit+1) for rows.Next() { - var payload []byte - if rows.Scan(&payload) != nil { - continue - } - var submission models.Submission - if json.Unmarshal(payload, &submission) == nil { - items = append(items, submission) + var item models.SubmissionSummary + if err := rows.Scan( + &item.ID, + &item.UserID, + &item.UserEmail, + &item.ProblemID, + &item.ProblemTitle, + &item.EvaluationRevision, + &item.Language, + &item.Status, + &item.PassedTests, + &item.TotalTests, + &item.Score, + &item.DurationMs, + &item.CreatedAt, + ); err != nil { + return SubmissionPageResult{}, fmt.Errorf("scan submission summary: %w", err) } + items = append(items, item) + } + if err := rows.Err(); err != nil { + return SubmissionPageResult{}, fmt.Errorf("iterate submission page: %w", err) + } + hasMore := len(items) > limit + if hasMore { + items = items[:limit] + } + return SubmissionPageResult{Items: items, HasMore: hasMore}, nil +} + +func (s *PostgresStore) LookupSubmissionByID(id, ownerUserID string) (models.Submission, bool, error) { + ctx, cancel := postgresRuntimeContext() + defer cancel() + + query := "SELECT data FROM submissions WHERE id = $1" + args := []any{id} + if ownerUserID != "" { + // Materializing the primary-key probe before checking ownership prevents + // a planner with stale/skewed tenant statistics from choosing an index + // that scans every row for a large owner. Both foreign and nonexistent + // IDs therefore inspect at most one table row. + query = `WITH submission_by_id AS MATERIALIZED ( + SELECT user_id, data FROM submissions WHERE id = $1 + ) + SELECT data FROM submission_by_id WHERE user_id = $2` + args = []any{id, ownerUserID} + } + var payload []byte + if err := s.db.QueryRowContext(ctx, query, args...).Scan(&payload); errors.Is(err, sql.ErrNoRows) { + return models.Submission{}, false, nil + } else if err != nil { + return models.Submission{}, false, fmt.Errorf("lookup submission by id: %w", err) } - return items + var submission models.Submission + if err := json.Unmarshal(payload, &submission); err != nil { + return models.Submission{}, false, fmt.Errorf("decode submission: %w", err) + } + return submission, true, nil +} + +func (s *PostgresStore) AggregateSubmissionProgress(userID string) (models.UserProgress, error) { + ctx, cancel := postgresRuntimeContext() + defer cancel() + + var progress models.UserProgress + var solvedProblemIDsJSON []byte + err := s.db.QueryRowContext(ctx, ` + SELECT COUNT(*)::INTEGER, + COUNT(*) FILTER (WHERE status = $2)::INTEGER, + COUNT(DISTINCT problem_id) FILTER (WHERE status = $2)::INTEGER, + COALESCE( + jsonb_agg(DISTINCT problem_id ORDER BY problem_id) + FILTER (WHERE status = $2), + '[]'::JSONB + ) + FROM submissions + WHERE user_id = $1`, userID, models.SubmissionStatusAccepted).Scan( + &progress.SubmissionCount, + &progress.AcceptedCount, + &progress.SolvedCount, + &solvedProblemIDsJSON, + ) + if err != nil { + return models.UserProgress{}, fmt.Errorf("aggregate submission progress: %w", err) + } + if err := json.Unmarshal(solvedProblemIDsJSON, &progress.SolvedProblemIDs); err != nil { + return models.UserProgress{}, fmt.Errorf("decode solved problem ids: %w", err) + } + return progress, nil } -func (s *PostgresStore) SaveSubmission(submission models.Submission) models.Submission { +func (s *PostgresStore) SaveSubmission(submission models.Submission) (models.Submission, error) { + ctx, cancel := postgresRuntimeContext() + defer cancel() + payload, err := json.Marshal(submission) if err != nil { - return models.Submission{} + return models.Submission{}, fmt.Errorf("encode submission: %w", err) } - _, err = s.db.Exec(`INSERT INTO submissions (id, user_id, problem_id, created_at, data) VALUES ($1, $2, $3, $4, $5)`, + _, err = s.db.ExecContext(ctx, `INSERT INTO submissions (id, user_id, problem_id, created_at, data) VALUES ($1, $2, $3, $4, $5)`, submission.ID, submission.UserID, submission.ProblemID, submission.CreatedAt, payload) if err != nil { - return models.Submission{} + return models.Submission{}, fmt.Errorf("save submission: %w", err) } - return submission + return submission, nil } func (s *PostgresStore) GetWorkspace(userID, problemID string, language models.Language) (models.Workspace, bool) { + workspace, ok, _ := s.LookupWorkspace(userID, problemID, language) + return workspace, ok +} + +func (s *PostgresStore) LookupWorkspace(userID, problemID string, language models.Language) (models.Workspace, bool, error) { + ctx, cancel := postgresRuntimeContext() + defer cancel() + var payload []byte - if err := s.db.QueryRow("SELECT data FROM workspaces WHERE user_id = $1 AND problem_id = $2 AND language = $3", userID, problemID, language).Scan(&payload); err != nil { - return models.Workspace{}, false + var revision int64 + var updatedAt time.Time + err := s.db.QueryRowContext(ctx, "SELECT data, revision, updated_at FROM workspaces WHERE user_id = $1 AND problem_id = $2 AND language = $3", userID, problemID, language).Scan(&payload, &revision, &updatedAt) + if errors.Is(err, sql.ErrNoRows) { + return models.Workspace{}, false, nil + } + if err != nil { + return models.Workspace{}, false, fmt.Errorf("lookup workspace: %w", err) } var workspace models.Workspace - return workspace, json.Unmarshal(payload, &workspace) == nil + if err := json.Unmarshal(payload, &workspace); err != nil { + return models.Workspace{}, false, fmt.Errorf("decode workspace: %w", err) + } + workspace.Revision = revision + workspace.UpdatedAt = updatedAt + return workspace, true, nil +} + +func (s *PostgresStore) ProblemExists(id string) (bool, error) { + ctx, cancel := postgresRuntimeContext() + defer cancel() + + var exists bool + if err := s.db.QueryRowContext(ctx, "SELECT EXISTS (SELECT 1 FROM problems WHERE id = $1)", id).Scan(&exists); err != nil { + return false, fmt.Errorf("check problem existence: %w", err) + } + return exists, nil } -func (s *PostgresStore) SaveWorkspace(workspace models.Workspace) models.Workspace { - workspace.UpdatedAt = time.Now() +func (s *PostgresStore) SaveWorkspace(workspace models.Workspace) (models.Workspace, error) { + ctx, cancel := postgresRuntimeContext() + defer cancel() + + expectedRevision := workspace.Revision + if expectedRevision < 0 { + return models.Workspace{}, fmt.Errorf("%w: workspace revision cannot be negative", ErrConflict) + } + workspace.Revision = expectedRevision + 1 + workspace.UpdatedAt = time.Now().UTC() payload, err := json.Marshal(workspace) if err != nil { - return models.Workspace{} + return models.Workspace{}, fmt.Errorf("encode workspace: %w", err) + } + err = s.db.QueryRowContext(ctx, ` + WITH updated AS ( + UPDATE workspaces + SET revision = revision + 1, updated_at = $4, data = $5 + WHERE user_id = $1 AND problem_id = $2 AND language = $3 + AND revision = $6 AND $6 > 0 + RETURNING revision, updated_at + ), inserted AS ( + INSERT INTO workspaces (user_id, problem_id, language, revision, updated_at, data) + SELECT $1, $2, $3, 1, $4, $5 + WHERE $6 = 0 + ON CONFLICT (user_id, problem_id, language) DO NOTHING + RETURNING revision, updated_at + ) + SELECT revision, updated_at FROM updated + UNION ALL + SELECT revision, updated_at FROM inserted + LIMIT 1`, + workspace.UserID, workspace.ProblemID, workspace.Language, workspace.UpdatedAt, payload, expectedRevision, + ).Scan(&workspace.Revision, &workspace.UpdatedAt) + if errors.Is(err, sql.ErrNoRows) { + return models.Workspace{}, fmt.Errorf("%w: workspace changed after it was loaded", ErrConflict) } - _, err = s.db.Exec(`INSERT INTO workspaces (user_id, problem_id, language, updated_at, data) VALUES ($1, $2, $3, $4, $5) - ON CONFLICT (user_id, problem_id, language) DO UPDATE SET updated_at = EXCLUDED.updated_at, data = EXCLUDED.data`, - workspace.UserID, workspace.ProblemID, workspace.Language, workspace.UpdatedAt, payload) if err != nil { - return models.Workspace{} + return models.Workspace{}, fmt.Errorf("save workspace: %w", err) } - return workspace + return workspace, nil } -func (s *PostgresStore) DeleteWorkspace(userID, problemID string, language models.Language) bool { - result, err := s.db.Exec("DELETE FROM workspaces WHERE user_id = $1 AND problem_id = $2 AND language = $3", userID, problemID, language) +func (s *PostgresStore) DeleteWorkspace(userID, problemID string, language models.Language) (bool, error) { + ctx, cancel := postgresRuntimeContext() + defer cancel() + + result, err := s.db.ExecContext(ctx, "DELETE FROM workspaces WHERE user_id = $1 AND problem_id = $2 AND language = $3", userID, problemID, language) if err != nil { - return false + return false, fmt.Errorf("delete workspace: %w", err) } - count, _ := result.RowsAffected() - return count == 1 + count, err := result.RowsAffected() + if err != nil { + return false, fmt.Errorf("inspect deleted workspace: %w", err) + } + return count == 1, nil } -func (s *PostgresStore) SaveRefreshToken(token models.RefreshToken) { +func (s *PostgresStore) SaveRefreshToken(token models.RefreshToken) error { + ctx, cancel := postgresRuntimeContext() + defer cancel() + if token.FamilyID == "" { token.FamilyID = token.ID } - _, _ = s.db.Exec(`INSERT INTO refresh_tokens (id, user_id, family_id, token_hash, expires_at, created_at, revoked_at) + _, err := s.db.ExecContext(ctx, `INSERT INTO refresh_tokens (id, user_id, family_id, token_hash, expires_at, created_at, revoked_at) VALUES ($1, $2, $3, $4, $5, $6, $7)`, token.ID, token.UserID, token.FamilyID, token.TokenHash, token.ExpiresAt, token.CreatedAt, token.RevokedAt) + if err != nil { + return fmt.Errorf("save refresh token: %w", err) + } + // Retention must not gate credential issuance. ExecContext returning means + // the autocommit INSERT is durable; cleanup runs afterwards and is bounded, + // best-effort maintenance. + _ = s.cleanupExpiredAuthArtifacts(time.Now()) + return nil } func (s *PostgresStore) GetRefreshTokenByHash(tokenHash string) (models.RefreshToken, bool) { + ctx, cancel := postgresRuntimeContext() + defer cancel() + var token models.RefreshToken - err := s.db.QueryRow(`SELECT id, user_id, COALESCE(family_id, id), token_hash, expires_at, created_at, revoked_at + err := s.db.QueryRowContext(ctx, `SELECT id, user_id, family_id, token_hash, expires_at, created_at, revoked_at FROM refresh_tokens WHERE token_hash = $1`, tokenHash). Scan(&token.ID, &token.UserID, &token.FamilyID, &token.TokenHash, &token.ExpiresAt, &token.CreatedAt, &token.RevokedAt) return token, err == nil } +func (s *PostgresStore) RotateRefreshToken(tokenHash string, replacement models.RefreshToken, now time.Time) (models.RefreshToken, RefreshTokenRotationOutcome, error) { + if replacement.ID == "" || replacement.TokenHash == "" { + return models.RefreshToken{}, "", errors.New("replacement refresh token id and hash are required") + } + + ctx, cancel := postgresRuntimeContext() + defer cancel() + tx, err := s.beginRefreshTokenFamilyTx(ctx) + if err != nil { + return models.RefreshToken{}, "", fmt.Errorf("begin refresh token rotation: %w", err) + } + defer func() { _ = tx.Rollback() }() + + familyID, found, err := lookupRefreshTokenFamily(ctx, tx, tokenHash) + if err != nil { + return models.RefreshToken{}, "", err + } + if !found { + return models.RefreshToken{}, RefreshTokenNotFound, nil + } + if err := lockRefreshTokenFamily(ctx, tx, familyID); err != nil { + return models.RefreshToken{}, "", err + } + + var old models.RefreshToken + err = tx.QueryRowContext(ctx, `SELECT id, user_id, family_id, token_hash, expires_at, created_at, revoked_at + FROM refresh_tokens WHERE token_hash = $1 FOR UPDATE`, tokenHash). + Scan(&old.ID, &old.UserID, &old.FamilyID, &old.TokenHash, &old.ExpiresAt, &old.CreatedAt, &old.RevokedAt) + if errors.Is(err, sql.ErrNoRows) { + return models.RefreshToken{}, RefreshTokenNotFound, nil + } + if err != nil { + return models.RefreshToken{}, "", fmt.Errorf("lock refresh token for rotation: %w", err) + } + if old.FamilyID != familyID { + return models.RefreshToken{}, "", errors.New("refresh token family changed during rotation") + } + + if old.RevokedAt != nil { + if _, err := revokeRefreshTokenFamilyTx(ctx, tx, old.FamilyID, now); err != nil { + return old, "", fmt.Errorf("revoke replayed refresh token family: %w", err) + } + if err := tx.Commit(); err != nil { + return old, "", fmt.Errorf("commit replayed refresh token revocation: %w", err) + } + return old, RefreshTokenReused, nil + } + + if !old.ExpiresAt.After(now) { + if _, err := revokeRefreshTokenFamilyTx(ctx, tx, old.FamilyID, now); err != nil { + return old, "", fmt.Errorf("revoke expired refresh token family: %w", err) + } + if err := tx.Commit(); err != nil { + return old, "", fmt.Errorf("commit expired refresh token revocation: %w", err) + } + old.RevokedAt = timePointer(now) + return old, RefreshTokenExpired, nil + } + + replacement.UserID = old.UserID + replacement.FamilyID = old.FamilyID + replacement.RevokedAt = nil + // The active credential carries the family's absolute deadline forward. + // Rotation therefore never extends the lifetime chosen at initial login. + replacement.ExpiresAt = old.ExpiresAt + + var generations int + if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM refresh_tokens + WHERE family_id = $1`, old.FamilyID).Scan(&generations); err != nil { + return old, "", fmt.Errorf("count refresh token family generations: %w", err) + } + if generations >= refreshTokenFamilyMaxGenerations { + if _, err := revokeRefreshTokenFamilyTx(ctx, tx, old.FamilyID, now); err != nil { + return old, "", fmt.Errorf("revoke refresh token family at generation limit: %w", err) + } + if err := tx.Commit(); err != nil { + return old, "", fmt.Errorf("commit refresh token generation-limit revocation: %w", err) + } + old.RevokedAt = timePointer(now) + return old, RefreshTokenGenerationLimit, nil + } + + result, err := tx.ExecContext(ctx, `UPDATE refresh_tokens SET revoked_at = $2 + WHERE id = $1 AND revoked_at IS NULL`, old.ID, now) + if err != nil { + return old, "", fmt.Errorf("consume refresh token: %w", err) + } + rows, err := result.RowsAffected() + if err != nil { + return old, "", fmt.Errorf("inspect refresh token consumption: %w", err) + } + if rows != 1 { + return old, "", errors.New("refresh token changed during rotation") + } + if _, err := tx.ExecContext(ctx, `INSERT INTO refresh_tokens + (id, user_id, family_id, token_hash, expires_at, created_at, revoked_at) + VALUES ($1, $2, $3, $4, $5, $6, $7)`, + replacement.ID, replacement.UserID, replacement.FamilyID, replacement.TokenHash, + replacement.ExpiresAt, replacement.CreatedAt, replacement.RevokedAt); err != nil { + return old, "", fmt.Errorf("save replacement refresh token: %w", err) + } + if err := tx.Commit(); err != nil { + return old, "", fmt.Errorf("commit refresh token rotation: %w", err) + } + old.RevokedAt = timePointer(now) + // Rotation has already committed; retention is best-effort and must never + // turn a valid newly issued credential into a client-visible failure. + _ = s.cleanupExpiredAuthArtifacts(now) + return old, RefreshTokenRotated, nil +} + +func lookupRefreshTokenFamily(ctx context.Context, tx *sql.Tx, tokenHash string) (string, bool, error) { + var familyID string + err := tx.QueryRowContext(ctx, `SELECT family_id FROM refresh_tokens WHERE token_hash = $1`, tokenHash).Scan(&familyID) + if errors.Is(err, sql.ErrNoRows) { + return "", false, nil + } + if err != nil { + return "", false, fmt.Errorf("lookup refresh token family: %w", err) + } + return familyID, true, nil +} + +func (s *PostgresStore) beginRefreshTokenFamilyTx(ctx context.Context) (*sql.Tx, error) { + // A waiter must observe the advisory-lock winner's committed replacement in + // its next statement. Pinning READ COMMITTED avoids inheriting a deployment + // default whose transaction-wide snapshot could reintroduce the race. + return s.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted}) +} + +func lockRefreshTokenFamily(ctx context.Context, tx *sql.Tx, familyID string) error { + if familyID == "" { + return errors.New("refresh token family is required") + } + if _, err := tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock($1, hashtext($2))`, int32(refreshTokenAdvisoryNamespace), familyID); err != nil { + return fmt.Errorf("lock refresh token family: %w", err) + } + return nil +} + +func revokeRefreshTokenFamilyTx(ctx context.Context, tx *sql.Tx, familyID string, now time.Time) (int64, error) { + result, err := tx.ExecContext(ctx, `UPDATE refresh_tokens SET revoked_at = $2 + WHERE family_id = $1 AND revoked_at IS NULL`, familyID, now) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + func (s *PostgresStore) RevokeRefreshToken(id string) bool { - result, err := s.db.Exec("UPDATE refresh_tokens SET revoked_at = NOW() WHERE id = $1 AND revoked_at IS NULL", id) + ctx, cancel := postgresRuntimeContext() + defer cancel() + + result, err := s.db.ExecContext(ctx, "UPDATE refresh_tokens SET revoked_at = NOW() WHERE id = $1 AND revoked_at IS NULL", id) if err != nil { return false } @@ -184,39 +653,143 @@ func (s *PostgresStore) RevokeRefreshToken(id string) bool { } func (s *PostgresStore) RevokeRefreshTokenFamily(familyID string) bool { - result, err := s.db.Exec("UPDATE refresh_tokens SET revoked_at = NOW() WHERE family_id = $1 AND revoked_at IS NULL", familyID) + if familyID == "" { + return false + } + ctx, cancel := postgresRuntimeContext() + defer cancel() + tx, err := s.beginRefreshTokenFamilyTx(ctx) if err != nil { return false } - count, _ := result.RowsAffected() + defer func() { _ = tx.Rollback() }() + if err := lockRefreshTokenFamily(ctx, tx, familyID); err != nil { + return false + } + count, err := revokeRefreshTokenFamilyTx(ctx, tx, familyID, time.Now()) + if err != nil { + return false + } + if err := tx.Commit(); err != nil { + return false + } return count > 0 } +func (s *PostgresStore) RevokeRefreshTokenFamilyByHash(tokenHash string, now time.Time) (bool, error) { + ctx, cancel := postgresRuntimeContext() + defer cancel() + tx, err := s.beginRefreshTokenFamilyTx(ctx) + if err != nil { + return false, fmt.Errorf("begin refresh token family revocation: %w", err) + } + defer func() { _ = tx.Rollback() }() + + familyID, found, err := lookupRefreshTokenFamily(ctx, tx, tokenHash) + if err != nil { + return false, err + } + if !found { + return false, nil + } + if err := lockRefreshTokenFamily(ctx, tx, familyID); err != nil { + return false, err + } + count, err := revokeRefreshTokenFamilyTx(ctx, tx, familyID, now) + if err != nil { + return false, fmt.Errorf("revoke refresh token family by hash: %w", err) + } + if err := tx.Commit(); err != nil { + return false, fmt.Errorf("commit refresh token family revocation: %w", err) + } + return count > 0, nil +} + func (s *PostgresStore) SaveAuthCode(codeHash, userID string, expiresAt time.Time) error { - _, err := s.db.Exec(`INSERT INTO auth_codes (code_hash, user_id, expires_at, created_at) VALUES ($1, $2, $3, NOW())`, + ctx, cancel := postgresRuntimeContext() + defer cancel() + + _, err := s.db.ExecContext(ctx, `INSERT INTO auth_codes (code_hash, user_id, expires_at, created_at) VALUES ($1, $2, $3, NOW())`, codeHash, userID, expiresAt) - return err + if err != nil { + return err + } + // As with refresh tokens, a cleanup outage cannot invalidate a code that was + // already issued successfully. + _ = s.cleanupExpiredAuthArtifacts(time.Now()) + return nil +} + +func (s *PostgresStore) cleanupExpiredAuthArtifacts(now time.Time) error { + ctx, cancel := context.WithTimeout(context.Background(), authArtifactCleanupTimeout) + defer cancel() + cutoff := now.Add(-authArtifactExpiryGrace) + + if _, err := s.db.ExecContext(ctx, ` + WITH stale AS ( + SELECT id + FROM refresh_tokens + WHERE expires_at < $1 + ORDER BY expires_at + LIMIT $2 + FOR UPDATE SKIP LOCKED + ) + DELETE FROM refresh_tokens AS token + USING stale + WHERE token.id = stale.id`, cutoff, authArtifactCleanupBatch); err != nil { + return fmt.Errorf("cleanup expired refresh tokens: %w", err) + } + + if _, err := s.db.ExecContext(ctx, ` + WITH stale AS ( + SELECT code_hash + FROM auth_codes + WHERE expires_at < $1 + ORDER BY expires_at + LIMIT $2 + FOR UPDATE SKIP LOCKED + ) + DELETE FROM auth_codes AS code + USING stale + WHERE code.code_hash = stale.code_hash`, cutoff, authArtifactCleanupBatch); err != nil { + return fmt.Errorf("cleanup expired auth codes: %w", err) + } + return nil } func (s *PostgresStore) ConsumeAuthCode(codeHash string) (userID string, ok bool) { - tx, err := s.db.Begin() + userID, ok, _ = s.ConsumeAuthCodeWithError(codeHash) + return userID, ok +} + +func (s *PostgresStore) ConsumeAuthCodeWithError(codeHash string) (userID string, ok bool, err error) { + ctx, cancel := postgresRuntimeContext() + defer cancel() + + tx, err := s.db.BeginTx(ctx, nil) if err != nil { - return "", false + return "", false, fmt.Errorf("begin auth code consumption: %w", err) } defer func() { _ = tx.Rollback() }() var expiresAt time.Time var usedAt sql.NullTime - err = tx.QueryRow(`SELECT user_id, expires_at, used_at FROM auth_codes WHERE code_hash = $1 FOR UPDATE`, codeHash). + err = tx.QueryRowContext(ctx, `SELECT user_id, expires_at, used_at FROM auth_codes WHERE code_hash = $1 FOR UPDATE`, codeHash). Scan(&userID, &expiresAt, &usedAt) - if err != nil || usedAt.Valid || time.Now().After(expiresAt) { - return "", false + if errors.Is(err, sql.ErrNoRows) { + return "", false, nil + } + if err != nil { + return "", false, fmt.Errorf("lookup auth code: %w", err) + } + if usedAt.Valid || time.Now().After(expiresAt) { + return "", false, nil } - if _, err := tx.Exec(`UPDATE auth_codes SET used_at = NOW() WHERE code_hash = $1`, codeHash); err != nil { - return "", false + if _, err := tx.ExecContext(ctx, `UPDATE auth_codes SET used_at = NOW() WHERE code_hash = $1`, codeHash); err != nil { + return "", false, fmt.Errorf("consume auth code: %w", err) } if err := tx.Commit(); err != nil { - return "", false + return "", false, fmt.Errorf("commit auth code consumption: %w", err) } - return userID, true + return userID, true, nil } diff --git a/interviewhub-backend-go/internal/store/postgres_problems.go b/interviewhub-backend-go/internal/store/postgres_problems.go index 578eae98..31d5be59 100644 --- a/interviewhub-backend-go/internal/store/postgres_problems.go +++ b/interviewhub-backend-go/internal/store/postgres_problems.go @@ -1,38 +1,138 @@ package store import ( + "context" "database/sql" "encoding/json" + "errors" + "fmt" + "strings" "time" "github.com/interviewhub/backend/internal/data" "github.com/interviewhub/backend/internal/models" + "github.com/jackc/pgx/v5/pgconn" ) +type problemReadQueryer interface { + QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) + QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row +} + +const problemCatalogMutationLock = "judgemycode_problem_catalog_mutations" + +// lockProblemCatalogMutation gives every whole-document catalog writer the +// same transaction-scoped lock. Row-level CAS remains the stale-write guard; +// this lock establishes a consistent order with multi-row seed sync/reset +// operations and prevents their satellite replacements from interleaving. +func lockProblemCatalogMutation(ctx context.Context, tx *sql.Tx) error { + if _, err := tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock(hashtext($1))`, problemCatalogMutationLock); err != nil { + return fmt.Errorf("acquire problem catalog mutation lock: %w", err) + } + return nil +} + +// GetAllProblems is retained for Repository compatibility. Request paths that +// need to distinguish an empty catalog from a storage failure use +// LookupAllProblems instead. func (s *PostgresStore) GetAllProblems() []models.Problem { - rows, err := s.db.Query(`SELECT id FROM problems ORDER BY updated_at DESC`) + problems, _ := s.LookupAllProblems() + return problems +} + +// GetProblemByID is retained for Repository compatibility. Mutation paths use +// LookupProblemByID so a failed satellite read can never be interpreted as an +// empty, safe-to-overwrite problem document. +func (s *PostgresStore) GetProblemByID(id string) (models.Problem, bool) { + problem, ok, _ := s.LookupProblemByID(id) + return problem, ok +} + +// LookupAllProblems returns a catalog assembled from one repeatable-read +// snapshot. A problem is never omitted merely because one of its rows could +// not be scanned or decoded. +func (s *PostgresStore) LookupAllProblems() ([]models.Problem, error) { + ctx, cancel := postgresRuntimeContext() + defer cancel() + + tx, err := s.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelRepeatableRead, ReadOnly: true}) + if err != nil { + return nil, fmt.Errorf("begin problem catalog read: %w", err) + } + defer func() { _ = tx.Rollback() }() + + ids, err := loadProblemIDs(ctx, tx) + if err != nil { + return nil, err + } + problems := make([]models.Problem, 0, len(ids)) + for _, id := range ids { + problem, ok, err := lookupProblemByID(ctx, tx, id) + if err != nil { + return nil, err + } + if !ok { + return nil, fmt.Errorf("problem %q disappeared from a repeatable-read catalog snapshot", id) + } + problems = append(problems, problem) + } + if err := tx.Commit(); err != nil { + return nil, fmt.Errorf("complete problem catalog read: %w", err) + } + return problems, nil +} + +// LookupProblemByID assembles the statement, boilerplates, references, and +// public/hidden tests within one snapshot. Any incomplete component fails the +// whole read. +func (s *PostgresStore) LookupProblemByID(id string) (models.Problem, bool, error) { + ctx, cancel := postgresRuntimeContext() + defer cancel() + + tx, err := s.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelRepeatableRead, ReadOnly: true}) if err != nil { - return nil + return models.Problem{}, false, fmt.Errorf("begin problem %q read: %w", id, err) + } + defer func() { _ = tx.Rollback() }() + + problem, ok, err := lookupProblemByID(ctx, tx, id) + if err != nil { + return models.Problem{}, false, err + } + if err := tx.Commit(); err != nil { + return models.Problem{}, false, fmt.Errorf("complete problem %q read: %w", id, err) + } + return problem, ok, nil +} + +func loadProblemIDs(ctx context.Context, queryer problemReadQueryer) ([]string, error) { + rows, err := queryer.QueryContext(ctx, `SELECT id FROM problems ORDER BY updated_at DESC`) + if err != nil { + return nil, fmt.Errorf("list problem IDs: %w", err) } defer rows.Close() - problems := make([]models.Problem, 0) + ids := make([]string, 0) for rows.Next() { var id string - if rows.Scan(&id) != nil { - continue - } - if problem, ok := s.GetProblemByID(id); ok { - problems = append(problems, problem) + if err := rows.Scan(&id); err != nil { + return nil, fmt.Errorf("scan problem ID: %w", err) } + ids = append(ids, id) } - return problems + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate problem IDs: %w", err) + } + if err := rows.Close(); err != nil { + return nil, fmt.Errorf("close problem ID rows: %w", err) + } + return ids, nil } -func (s *PostgresStore) GetProblemByID(id string) (models.Problem, bool) { +func lookupProblemByID(ctx context.Context, queryer problemReadQueryer, id string) (models.Problem, bool, error) { var problem models.Problem var companies, functional, nonFunctional, constraints, apis, criteria, tips []byte - err := s.db.QueryRow(` + err := queryer.QueryRowContext(ctx, ` SELECT id, title, subtitle, difficulty, estimated_time, companies, description, functional_requirements, non_functional_requirements, constraints_list, apis, evaluation_criteria, tips, solution_explanation, class_diagram_mermaid, @@ -43,71 +143,149 @@ func (s *PostgresStore) GetProblemByID(id string) (models.Problem, bool) { &criteria, &tips, &problem.SolutionExplanation, &problem.ClassDiagramMermaid, &problem.CreatedAt, &problem.UpdatedAt, ) + if errors.Is(err, sql.ErrNoRows) { + return models.Problem{}, false, nil + } if err != nil { - // Legacy fallback: entire document in JSONB `data` - return s.getProblemFromLegacyJSONB(id) + return models.Problem{}, false, fmt.Errorf("read problem %q statement: %w", id, err) } - _ = json.Unmarshal(companies, &problem.Companies) - _ = json.Unmarshal(functional, &problem.FunctionalRequirements) - _ = json.Unmarshal(nonFunctional, &problem.NonFunctionalRequirements) - _ = json.Unmarshal(constraints, &problem.Constraints) - _ = json.Unmarshal(apis, &problem.APIs) - _ = json.Unmarshal(criteria, &problem.EvaluationCriteria) - _ = json.Unmarshal(tips, &problem.Tips) + jsonFields := []struct { + name string + payload []byte + target any + }{ + {name: "companies", payload: companies, target: &problem.Companies}, + {name: "functional requirements", payload: functional, target: &problem.FunctionalRequirements}, + {name: "non-functional requirements", payload: nonFunctional, target: &problem.NonFunctionalRequirements}, + {name: "constraints", payload: constraints, target: &problem.Constraints}, + {name: "APIs", payload: apis, target: &problem.APIs}, + {name: "evaluation criteria", payload: criteria, target: &problem.EvaluationCriteria}, + {name: "tips", payload: tips, target: &problem.Tips}, + } + for _, field := range jsonFields { + if err := json.Unmarshal(field.payload, field.target); err != nil { + return models.Problem{}, false, fmt.Errorf("decode problem %q %s: %w", id, field.name, err) + } + } - problem.StarterFiles, problem.BoilerplateRefs = s.loadBoilerplates(id) - problem.TestCases, problem.HiddenTestCases = s.loadTestCases(id) + problem.StarterFiles, problem.BoilerplateRefs, err = loadBoilerplates(ctx, queryer, id) + if err != nil { + return models.Problem{}, false, err + } + problem.TestCases, problem.HiddenTestCases, err = loadTestCases(ctx, queryer, id) + if err != nil { + return models.Problem{}, false, err + } - if len(problem.StarterFiles) == 0 && len(problem.TestCases) == 0 { - if legacy, ok := s.getProblemFromLegacyJSONB(id); ok { - if len(problem.StarterFiles) == 0 { - problem.StarterFiles = legacy.StarterFiles - } - if len(problem.TestCases) == 0 { - problem.TestCases = legacy.TestCases - } - if len(problem.HiddenTestCases) == 0 { - problem.HiddenTestCases = legacy.HiddenTestCases - } - } + // Migration 004 normalized statement fields but intentionally left older + // starter files and tests in the legacy document. Merge only satellite + // collections that have not yet been materialized. New writes store a valid + // compact legacy document, so this remains deterministic after migration. + legacy, ok, err := getProblemFromLegacyJSONB(ctx, queryer, id) + if err != nil { + return models.Problem{}, false, err + } + if ok { + mergeLegacyProblemSatellites(&problem, legacy) } normalizeProblemDefaults(&problem) - return problem, true + return problem, true, nil } -func (s *PostgresStore) getProblemFromLegacyJSONB(id string) (models.Problem, bool) { +func mergeLegacyProblemSatellites(problem *models.Problem, legacy models.Problem) { + if problem.StarterFiles == nil { + problem.StarterFiles = make(map[models.Language][]models.FileNode) + } + for language, files := range legacy.StarterFiles { + if _, materialized := problem.StarterFiles[language]; !materialized { + problem.StarterFiles[language] = files + } + } + if problem.BoilerplateRefs == nil && len(legacy.BoilerplateRefs) > 0 { + problem.BoilerplateRefs = make(map[models.Language]string, len(legacy.BoilerplateRefs)) + } + for language, reference := range legacy.BoilerplateRefs { + if _, materialized := problem.BoilerplateRefs[language]; !materialized { + problem.BoilerplateRefs[language] = reference + } + } + + seenTestIDs := make(map[string]struct{}, len(problem.TestCases)+len(problem.HiddenTestCases)) + for _, testCase := range problem.TestCases { + if testCase.ID != "" { + seenTestIDs[testCase.ID] = struct{}{} + } + } + for _, testCase := range problem.HiddenTestCases { + if testCase.ID != "" { + seenTestIDs[testCase.ID] = struct{}{} + } + } + mergeTestCase := func(testCase models.TestCase, forceHidden bool) { + if _, materialized := seenTestIDs[testCase.ID]; materialized && testCase.ID != "" { + return + } + if testCase.ID != "" { + seenTestIDs[testCase.ID] = struct{}{} + } + if forceHidden || testCase.Visibility == models.TestHidden { + testCase.Visibility = models.TestHidden + problem.HiddenTestCases = append(problem.HiddenTestCases, testCase) + return + } + problem.TestCases = append(problem.TestCases, testCase) + } + for _, testCase := range legacy.TestCases { + mergeTestCase(testCase, false) + } + for _, testCase := range legacy.HiddenTestCases { + mergeTestCase(testCase, true) + } +} + +func getProblemFromLegacyJSONB(ctx context.Context, queryer problemReadQueryer, id string) (models.Problem, bool, error) { var payload []byte - if err := s.db.QueryRow(`SELECT data FROM problems WHERE id = $1`, id).Scan(&payload); err != nil || len(payload) == 0 { - return models.Problem{}, false + err := queryer.QueryRowContext(ctx, `SELECT data FROM problems WHERE id = $1`, id).Scan(&payload) + if errors.Is(err, sql.ErrNoRows) { + return models.Problem{}, false, nil + } + if err != nil { + return models.Problem{}, false, fmt.Errorf("read problem %q legacy document: %w", id, err) + } + if len(payload) == 0 { + return models.Problem{}, false, fmt.Errorf("problem %q legacy document is empty", id) } var problem models.Problem - if json.Unmarshal(payload, &problem) != nil || problem.ID == "" { - return models.Problem{}, false + if err := json.Unmarshal(payload, &problem); err != nil { + return models.Problem{}, false, fmt.Errorf("decode problem %q legacy document: %w", id, err) + } + if problem.ID != id { + return models.Problem{}, false, fmt.Errorf("problem %q legacy document has mismatched ID %q", id, problem.ID) } normalizeProblemDefaults(&problem) - return problem, true + return problem, true, nil } -func (s *PostgresStore) loadBoilerplates(problemID string) (map[models.Language][]models.FileNode, map[models.Language]string) { +func loadBoilerplates(ctx context.Context, queryer problemReadQueryer, problemID string) (map[models.Language][]models.FileNode, map[models.Language]string, error) { files := make(map[models.Language][]models.FileNode) refs := make(map[models.Language]string) - rows, err := s.db.Query(`SELECT language, files, storage_ref FROM problem_boilerplates WHERE problem_id = $1`, problemID) + rows, err := queryer.QueryContext(ctx, `SELECT language, files, storage_ref FROM problem_boilerplates WHERE problem_id = $1`, problemID) if err != nil { - return files, refs + return nil, nil, fmt.Errorf("read problem %q boilerplates: %w", problemID, err) } defer rows.Close() for rows.Next() { var language string var payload []byte var storageRef sql.NullString - if rows.Scan(&language, &payload, &storageRef) != nil { - continue + if err := rows.Scan(&language, &payload, &storageRef); err != nil { + return nil, nil, fmt.Errorf("scan problem %q boilerplate: %w", problemID, err) } var tree []models.FileNode - if json.Unmarshal(payload, &tree) != nil { - continue + if err := json.Unmarshal(payload, &tree); err != nil { + return nil, nil, fmt.Errorf("decode problem %q %s boilerplate: %w", problemID, language, err) } lang := models.Language(language) files[lang] = tree @@ -115,28 +293,34 @@ func (s *PostgresStore) loadBoilerplates(problemID string) (map[models.Language] refs[lang] = storageRef.String } } - return files, refs + if err := rows.Err(); err != nil { + return nil, nil, fmt.Errorf("iterate problem %q boilerplates: %w", problemID, err) + } + if err := rows.Close(); err != nil { + return nil, nil, fmt.Errorf("close problem %q boilerplates: %w", problemID, err) + } + return files, refs, nil } -func (s *PostgresStore) loadTestCases(problemID string) (publicCases, hiddenCases []models.TestCase) { +func loadTestCases(ctx context.Context, queryer problemReadQueryer, problemID string) (publicCases, hiddenCases []models.TestCase, err error) { publicCases = make([]models.TestCase, 0) hiddenCases = make([]models.TestCase, 0) - rows, err := s.db.Query(` + rows, err := queryer.QueryContext(ctx, ` SELECT id, name, description, category, visibility, api_method, input, expected_output, timeout_ms, concurrent_workers FROM problem_test_cases WHERE problem_id = $1 ORDER BY sort_order ASC, created_at ASC`, problemID) if err != nil { - return publicCases, hiddenCases + return nil, nil, fmt.Errorf("read problem %q test cases: %w", problemID, err) } defer rows.Close() for rows.Next() { var tc models.TestCase var category, visibility string - if rows.Scan(&tc.ID, &tc.Name, &tc.Description, &category, &visibility, &tc.APIMethod, - &tc.Input, &tc.ExpectedOutput, &tc.TimeoutMs, &tc.ConcurrentWorkers) != nil { - continue + if err := rows.Scan(&tc.ID, &tc.Name, &tc.Description, &category, &visibility, &tc.APIMethod, + &tc.Input, &tc.ExpectedOutput, &tc.TimeoutMs, &tc.ConcurrentWorkers); err != nil { + return nil, nil, fmt.Errorf("scan problem %q test case: %w", problemID, err) } tc.Category = models.TestCategory(category) tc.Visibility = models.TestVisibility(visibility) @@ -147,79 +331,184 @@ func (s *PostgresStore) loadTestCases(problemID string) (publicCases, hiddenCase publicCases = append(publicCases, tc) } } - return publicCases, hiddenCases + if err := rows.Err(); err != nil { + return nil, nil, fmt.Errorf("iterate problem %q test cases: %w", problemID, err) + } + if err := rows.Close(); err != nil { + return nil, nil, fmt.Errorf("close problem %q test cases: %w", problemID, err) + } + return publicCases, hiddenCases, nil +} + +func (s *PostgresStore) SaveProblem(problem models.Problem) (models.Problem, error) { + ctx, cancel := postgresRuntimeContext() + defer cancel() + + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return models.Problem{}, fmt.Errorf("begin problem save: %w", err) + } + defer func() { _ = tx.Rollback() }() + if err := lockProblemCatalogMutation(ctx, tx); err != nil { + return models.Problem{}, err + } + saved, err := saveProblemTx(ctx, tx, problem, nil) + if err != nil { + return models.Problem{}, err + } + if err := tx.Commit(); err != nil { + return models.Problem{}, fmt.Errorf("commit problem %s: %w", problem.ID, err) + } + return saved, nil +} + +// SaveProblemIfUnchanged performs an optimistic whole-document replacement. +// The guarded parent UPDATE and all boilerplate/test-case replacements share +// one transaction, so a conflict cannot leave partially replaced satellites. +func (s *PostgresStore) SaveProblemIfUnchanged(problem models.Problem, expectedUpdatedAt time.Time) (models.Problem, error) { + ctx, cancel := postgresRuntimeContext() + defer cancel() + + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return models.Problem{}, fmt.Errorf("begin guarded problem save: %w", err) + } + defer func() { _ = tx.Rollback() }() + if err := lockProblemCatalogMutation(ctx, tx); err != nil { + return models.Problem{}, err + } + saved, err := saveProblemTx(ctx, tx, problem, &expectedUpdatedAt) + if err != nil { + return models.Problem{}, err + } + if err := tx.Commit(); err != nil { + return models.Problem{}, fmt.Errorf("commit guarded problem %s save: %w", problem.ID, err) + } + return saved, nil } -func (s *PostgresStore) SaveProblem(problem models.Problem) models.Problem { - now := time.Now() +func saveProblemTx(ctx context.Context, tx *sql.Tx, problem models.Problem, expectedUpdatedAt *time.Time) (models.Problem, error) { + if strings.TrimSpace(problem.ID) == "" { + return models.Problem{}, errors.New("problem ID is required") + } + if expectedUpdatedAt != nil && expectedUpdatedAt.IsZero() { + return models.Problem{}, fmt.Errorf("%w: problem %s has no expected revision", ErrConflict, problem.ID) + } + now := nextProblemUpdatedAt(time.Time{}) + if expectedUpdatedAt != nil { + now = nextProblemUpdatedAt(*expectedUpdatedAt) + } if problem.CreatedAt.IsZero() { problem.CreatedAt = now } problem.UpdatedAt = now normalizeProblemDefaults(&problem) - companies, _ := json.Marshal(problem.Companies) - functional, _ := json.Marshal(problem.FunctionalRequirements) - nonFunctional, _ := json.Marshal(problem.NonFunctionalRequirements) - constraints, _ := json.Marshal(problem.Constraints) - apis, _ := json.Marshal(problem.APIs) - criteria, _ := json.Marshal(problem.EvaluationCriteria) - tips, _ := json.Marshal(problem.Tips) + marshal := func(field string, value any) ([]byte, error) { + payload, err := json.Marshal(value) + if err != nil { + return nil, fmt.Errorf("encode problem %s %s: %w", problem.ID, field, err) + } + return payload, nil + } + companies, err := marshal("companies", problem.Companies) + if err != nil { + return models.Problem{}, err + } + functional, err := marshal("functional requirements", problem.FunctionalRequirements) + if err != nil { + return models.Problem{}, err + } + nonFunctional, err := marshal("non-functional requirements", problem.NonFunctionalRequirements) + if err != nil { + return models.Problem{}, err + } + constraints, err := marshal("constraints", problem.Constraints) + if err != nil { + return models.Problem{}, err + } + apis, err := marshal("APIs", problem.APIs) + if err != nil { + return models.Problem{}, err + } + criteria, err := marshal("evaluation criteria", problem.EvaluationCriteria) + if err != nil { + return models.Problem{}, err + } + tips, err := marshal("tips", problem.Tips) + if err != nil { + return models.Problem{}, err + } // Keep a compact legacy blob for transitional readers; starter/tests live in satellite tables. - legacy, _ := json.Marshal(map[string]any{ + legacy, err := marshal("legacy document", map[string]any{ "id": problem.ID, "title": problem.Title, }) - - tx, err := s.db.Begin() if err != nil { - return models.Problem{} + return models.Problem{}, err } - defer func() { _ = tx.Rollback() }() - var createdAt time.Time - err = tx.QueryRow(` - INSERT INTO problems ( - id, data, created_at, updated_at, title, subtitle, difficulty, estimated_time, companies, - description, functional_requirements, non_functional_requirements, constraints_list, apis, - evaluation_criteria, tips, solution_explanation, class_diagram_mermaid - ) VALUES ( - $1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18 - ) - ON CONFLICT (id) DO UPDATE SET - data = EXCLUDED.data, - updated_at = EXCLUDED.updated_at, - title = EXCLUDED.title, - subtitle = EXCLUDED.subtitle, - difficulty = EXCLUDED.difficulty, - estimated_time = EXCLUDED.estimated_time, - companies = EXCLUDED.companies, - description = EXCLUDED.description, - functional_requirements = EXCLUDED.functional_requirements, - non_functional_requirements = EXCLUDED.non_functional_requirements, - constraints_list = EXCLUDED.constraints_list, - apis = EXCLUDED.apis, - evaluation_criteria = EXCLUDED.evaluation_criteria, - tips = EXCLUDED.tips, - solution_explanation = EXCLUDED.solution_explanation, - class_diagram_mermaid = EXCLUDED.class_diagram_mermaid - RETURNING created_at`, + statementArgs := []any{ problem.ID, legacy, problem.CreatedAt, problem.UpdatedAt, problem.Title, problem.Subtitle, problem.Difficulty, problem.EstimatedTime, companies, problem.Description, functional, nonFunctional, constraints, apis, criteria, tips, problem.SolutionExplanation, problem.ClassDiagramMermaid, - ).Scan(&createdAt) + } + var createdAt, storedUpdatedAt time.Time + if expectedUpdatedAt == nil { + err = tx.QueryRowContext(ctx, ` + INSERT INTO problems ( + id, data, created_at, updated_at, title, subtitle, difficulty, estimated_time, companies, + description, functional_requirements, non_functional_requirements, constraints_list, apis, + evaluation_criteria, tips, solution_explanation, class_diagram_mermaid + ) VALUES ( + $1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18 + ) + ON CONFLICT (id) DO UPDATE SET + data = EXCLUDED.data, + updated_at = GREATEST(EXCLUDED.updated_at, problems.updated_at + INTERVAL '1 microsecond'), + title = EXCLUDED.title, + subtitle = EXCLUDED.subtitle, + difficulty = EXCLUDED.difficulty, + estimated_time = EXCLUDED.estimated_time, + companies = EXCLUDED.companies, + description = EXCLUDED.description, + functional_requirements = EXCLUDED.functional_requirements, + non_functional_requirements = EXCLUDED.non_functional_requirements, + constraints_list = EXCLUDED.constraints_list, + apis = EXCLUDED.apis, + evaluation_criteria = EXCLUDED.evaluation_criteria, + tips = EXCLUDED.tips, + solution_explanation = EXCLUDED.solution_explanation, + class_diagram_mermaid = EXCLUDED.class_diagram_mermaid + RETURNING created_at, updated_at`, statementArgs...).Scan(&createdAt, &storedUpdatedAt) + } else { + // The guarded UPDATE never writes created_at, so bind a compact argument + // list rather than leaving an unused placeholder that PostgreSQL cannot + // type-infer. + guardedArgs := [guardedProblemUpdateArgumentCount]any{ + problem.ID, legacy, problem.UpdatedAt, problem.Title, problem.Subtitle, + problem.Difficulty, problem.EstimatedTime, companies, problem.Description, functional, + nonFunctional, constraints, apis, criteria, tips, problem.SolutionExplanation, + problem.ClassDiagramMermaid, *expectedUpdatedAt, + } + err = tx.QueryRowContext(ctx, guardedProblemUpdateSQL, guardedArgs[:]...).Scan(&createdAt, &storedUpdatedAt) + } + if errors.Is(err, sql.ErrNoRows) && expectedUpdatedAt != nil { + return models.Problem{}, fmt.Errorf("%w: problem %s changed after it was loaded", ErrConflict, problem.ID) + } if err != nil { - return models.Problem{} + return models.Problem{}, fmt.Errorf("save problem %s statement: %w", problem.ID, err) } problem.CreatedAt = createdAt + problem.UpdatedAt = storedUpdatedAt - if _, err := tx.Exec(`DELETE FROM problem_boilerplates WHERE problem_id = $1`, problem.ID); err != nil { - return models.Problem{} + if _, err := tx.ExecContext(ctx, `DELETE FROM problem_boilerplates WHERE problem_id = $1`, problem.ID); err != nil { + return models.Problem{}, fmt.Errorf("replace boilerplates for problem %s: %w", problem.ID, err) } for language, tree := range problem.StarterFiles { payload, err := json.Marshal(tree) if err != nil { - return models.Problem{} + return models.Problem{}, fmt.Errorf("encode %s boilerplate for problem %s: %w", language, problem.ID, err) } var storageRef any if problem.BoilerplateRefs != nil { @@ -227,42 +516,24 @@ func (s *PostgresStore) SaveProblem(problem models.Problem) models.Problem { storageRef = ref } } - if _, err := tx.Exec(` + if _, err := tx.ExecContext(ctx, ` INSERT INTO problem_boilerplates (problem_id, language, files, storage_ref, updated_at) VALUES ($1, $2, $3, $4, $5)`, problem.ID, string(language), payload, storageRef, problem.UpdatedAt); err != nil { - return models.Problem{} + return models.Problem{}, fmt.Errorf("save %s boilerplate for problem %s: %w", language, problem.ID, err) } } - if _, err := tx.Exec(`DELETE FROM problem_test_cases WHERE problem_id = $1`, problem.ID); err != nil { - return models.Problem{} + if _, err := tx.ExecContext(ctx, `DELETE FROM problem_test_cases WHERE problem_id = $1`, problem.ID); err != nil { + return models.Problem{}, fmt.Errorf("replace test cases for problem %s: %w", problem.ID, err) } - allCases := make([]models.TestCase, 0, len(problem.TestCases)+len(problem.HiddenTestCases)) - seen := make(map[string]bool) - appendCase := func(tc models.TestCase, forceHidden bool) { - normalizeTestCase(&tc) - if forceHidden || tc.Visibility == models.TestHidden { - tc.Visibility = models.TestHidden - } - if tc.ID == "" || seen[tc.ID] { - return - } - seen[tc.ID] = true - allCases = append(allCases, tc) - } - for _, tc := range problem.TestCases { - appendCase(tc, false) - } - for _, tc := range problem.HiddenTestCases { - appendCase(tc, true) - } + allCases := normalizedProblemTestCases(problem) problem.TestCases = make([]models.TestCase, 0) problem.HiddenTestCases = make([]models.TestCase, 0) for i, tc := range allCases { - if _, err := tx.Exec(` + if _, err := tx.ExecContext(ctx, ` INSERT INTO problem_test_cases ( id, problem_id, name, description, category, visibility, api_method, input, expected_output, timeout_ms, concurrent_workers, sort_order, created_at @@ -270,7 +541,7 @@ func (s *PostgresStore) SaveProblem(problem models.Problem) models.Problem { tc.ID, problem.ID, tc.Name, tc.Description, string(tc.Category), string(tc.Visibility), tc.APIMethod, tc.Input, tc.ExpectedOutput, tc.TimeoutMs, tc.ConcurrentWorkers, i, now, ); err != nil { - return models.Problem{} + return models.Problem{}, fmt.Errorf("save test case %s for problem %s: %w", tc.ID, problem.ID, err) } if tc.Visibility == models.TestHidden { problem.HiddenTestCases = append(problem.HiddenTestCases, tc) @@ -279,29 +550,236 @@ func (s *PostgresStore) SaveProblem(problem models.Problem) models.Problem { } } + return problem, nil +} + +const guardedProblemUpdateArgumentCount = 18 + +const guardedProblemUpdateSQL = ` + UPDATE problems SET + data = $2, + updated_at = $3, + title = $4, + subtitle = $5, + difficulty = $6, + estimated_time = $7, + companies = $8, + description = $9, + functional_requirements = $10, + non_functional_requirements = $11, + constraints_list = $12, + apis = $13, + evaluation_criteria = $14, + tips = $15, + solution_explanation = $16, + class_diagram_mermaid = $17 + WHERE id = $1 AND updated_at = $18 + RETURNING created_at, updated_at` + +func (s *PostgresStore) DeleteProblem(id string) (bool, error) { + ctx, cancel := postgresRuntimeContext() + defer cancel() + + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return false, fmt.Errorf("begin problem %s delete: %w", id, err) + } + defer func() { _ = tx.Rollback() }() + if err := lockProblemCatalogMutation(ctx, tx); err != nil { + return false, err + } + + var lockedID string + err = tx.QueryRowContext(ctx, `SELECT id FROM problems WHERE id = $1 FOR UPDATE`, id).Scan(&lockedID) + if errors.Is(err, sql.ErrNoRows) { + return false, nil + } + if err != nil { + return false, fmt.Errorf("lock problem %s for delete: %w", id, err) + } + + if err := ensureProblemUnreferenced(ctx, tx, id); err != nil { + return false, err + } + + result, err := tx.ExecContext(ctx, "DELETE FROM problems WHERE id = $1", id) + if err != nil { + return false, problemDeleteError("delete problem "+id, err) + } + count, err := result.RowsAffected() + if err != nil { + return false, fmt.Errorf("read deleted problem count for %s: %w", id, err) + } + if count != 1 { + return false, fmt.Errorf("delete problem %s: expected one row, deleted %d", id, count) + } if err := tx.Commit(); err != nil { - return models.Problem{} + return false, fmt.Errorf("commit problem %s delete: %w", id, err) } - return problem + return true, nil } -func (s *PostgresStore) DeleteProblem(id string) bool { - result, err := s.db.Exec("DELETE FROM problems WHERE id = $1", id) +func (s *PostgresStore) ResetProblemsToDefaults() ([]models.Problem, error) { + ctx, cancel := postgresRuntimeContext() + defer cancel() + + tx, err := s.db.BeginTx(ctx, nil) if err != nil { - return false + return nil, fmt.Errorf("begin problem catalog reset: %w", err) + } + defer func() { _ = tx.Rollback() }() + if err := lockProblemCatalogMutation(ctx, tx); err != nil { + return nil, err + } + + // Lock obsolete parent rows before checking their children. PostgreSQL + // foreign-key inserts take a conflicting key-share lock, so no submission or + // workspace can appear between the safety check and deletion. + seeds := data.GetSeedProblems() + obsoleteIDs, err := lockObsoleteProblemsForReset(ctx, tx, seeds) + if err != nil { + return nil, err + } + for _, id := range obsoleteIDs { + if err := ensureProblemUnreferenced(ctx, tx, id); err != nil { + return nil, err + } } - count, _ := result.RowsAffected() - return count == 1 + + problems := make([]models.Problem, 0, len(seeds)) + for _, problem := range seeds { + saved, err := saveProblemTx(ctx, tx, problem, nil) + if err != nil { + return nil, fmt.Errorf("reset problem catalog at %s: %w", problem.ID, err) + } + problems = append(problems, saved) + } + for _, id := range obsoleteIDs { + result, err := tx.ExecContext(ctx, `DELETE FROM problems WHERE id = $1`, id) + if err != nil { + return nil, problemDeleteError("delete obsolete problem "+id, err) + } + deleted, err := result.RowsAffected() + if err != nil { + return nil, fmt.Errorf("read deleted obsolete problem count for %s: %w", id, err) + } + if deleted != 1 { + return nil, fmt.Errorf("delete obsolete problem %s: expected one row, deleted %d", id, deleted) + } + } + if err := tx.Commit(); err != nil { + return nil, fmt.Errorf("commit problem catalog reset: %w", err) + } + return problems, nil } -func (s *PostgresStore) ResetProblemsToDefaults() []models.Problem { - if _, err := s.db.Exec("DELETE FROM problems"); err != nil { - return nil +func lockObsoleteProblemsForReset(ctx context.Context, tx *sql.Tx, seeds []models.Problem) ([]string, error) { + query := `SELECT id FROM problems` + args := make([]any, 0, len(seeds)) + if len(seeds) > 0 { + placeholders := make([]string, 0, len(seeds)) + for index, problem := range seeds { + placeholders = append(placeholders, fmt.Sprintf("$%d", index+1)) + args = append(args, problem.ID) + } + query += ` WHERE id NOT IN (` + strings.Join(placeholders, ",") + `)` + } + query += ` ORDER BY id FOR UPDATE` + rows, err := tx.QueryContext(ctx, query, args...) + if err != nil { + return nil, fmt.Errorf("lock problem catalog for reset: %w", err) + } + defer rows.Close() + + obsoleteIDs := make([]string, 0) + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + return nil, fmt.Errorf("scan locked problem during reset: %w", err) + } + obsoleteIDs = append(obsoleteIDs, id) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate locked problems during reset: %w", err) + } + if err := rows.Close(); err != nil { + return nil, fmt.Errorf("close locked problems during reset: %w", err) + } + return obsoleteIDs, nil +} + +func problemHasReference(ctx context.Context, tx *sql.Tx, table, problemID string) (bool, error) { + var query string + switch table { + case "submissions": + query = `SELECT EXISTS (SELECT 1 FROM submissions WHERE problem_id = $1)` + case "workspaces": + query = `SELECT EXISTS (SELECT 1 FROM workspaces WHERE problem_id = $1)` + default: + return false, fmt.Errorf("unsupported problem reference table %q", table) + } + var exists bool + if err := tx.QueryRowContext(ctx, query, problemID).Scan(&exists); err != nil { + return false, fmt.Errorf("check %s references for obsolete problem %s: %w", table, problemID, err) + } + return exists, nil +} + +func ensureProblemUnreferenced(ctx context.Context, tx *sql.Tx, problemID string) error { + referenced, err := problemHasReference(ctx, tx, "submissions", problemID) + if err != nil { + return err + } + if referenced { + return fmt.Errorf("%w: submission history references problem %s", ErrProblemReferenced, problemID) + } + referenced, err = problemHasReference(ctx, tx, "workspaces", problemID) + if err != nil { + return err + } + if referenced { + return fmt.Errorf("%w: candidate workspace references problem %s", ErrProblemWorkspaceReferenced, problemID) + } + return nil +} + +func problemDeleteError(action string, err error) error { + var postgresError *pgconn.PgError + if errors.As(err, &postgresError) && postgresError.Code == "23503" { + return fmt.Errorf("%w: %s", ErrProblemReferenced, action) + } + return fmt.Errorf("%s: %w", action, err) +} + +func normalizedProblemTestCases(problem models.Problem) []models.TestCase { + allCases := make([]models.TestCase, 0, len(problem.TestCases)+len(problem.HiddenTestCases)) + seen := make(map[string]struct{}) + appendCase := func(testCase models.TestCase, forceHidden bool) { + normalizeTestCase(&testCase) + if forceHidden || testCase.Visibility == models.TestHidden { + testCase.Visibility = models.TestHidden + } + baseID := strings.TrimSpace(testCase.ID) + if baseID == "" { + baseID = fmt.Sprintf("tc-%d", len(allCases)+1) + } + testCase.ID = baseID + for suffix := 2; ; suffix++ { + if _, duplicate := seen[testCase.ID]; !duplicate { + break + } + testCase.ID = fmt.Sprintf("%s-%d", baseID, suffix) + } + seen[testCase.ID] = struct{}{} + allCases = append(allCases, testCase) + } + for _, testCase := range problem.TestCases { + appendCase(testCase, false) } - for _, problem := range data.GetSeedProblems() { - s.SaveProblem(problem) + for _, testCase := range problem.HiddenTestCases { + appendCase(testCase, true) } - return s.GetAllProblems() + return allCases } func normalizeProblemDefaults(problem *models.Problem) { diff --git a/interviewhub-backend-go/internal/store/postgres_problems_test.go b/interviewhub-backend-go/internal/store/postgres_problems_test.go new file mode 100644 index 00000000..65932aec --- /dev/null +++ b/interviewhub-backend-go/internal/store/postgres_problems_test.go @@ -0,0 +1,89 @@ +package store + +import ( + "regexp" + "strconv" + "testing" + + "github.com/interviewhub/backend/internal/models" +) + +func TestGuardedProblemUpdateSQLBindsEveryArgument(t *testing.T) { + matches := regexp.MustCompile(`\$(\d+)`).FindAllStringSubmatch(guardedProblemUpdateSQL, -1) + used := make(map[int]bool, guardedProblemUpdateArgumentCount) + for _, match := range matches { + position, err := strconv.Atoi(match[1]) + if err != nil { + t.Fatalf("parse SQL placeholder %q: %v", match[0], err) + } + if position > guardedProblemUpdateArgumentCount { + t.Fatalf("SQL uses placeholder $%d but only %d arguments are bound", position, guardedProblemUpdateArgumentCount) + } + used[position] = true + } + for position := 1; position <= guardedProblemUpdateArgumentCount; position++ { + if !used[position] { + t.Fatalf("SQL does not use bound argument $%d", position) + } + } +} + +func TestMergeLegacyProblemSatellitesPreservesMaterializedRowsAndFillsGaps(t *testing.T) { + problem := models.Problem{ + StarterFiles: map[models.Language][]models.FileNode{ + models.LangGo: {{ID: "go-current", Name: "main.go"}}, + }, + BoilerplateRefs: map[models.Language]string{models.LangGo: "go-current-ref"}, + TestCases: []models.TestCase{ + {ID: "public-current", Visibility: models.TestPublic, ExpectedOutput: "current"}, + }, + HiddenTestCases: []models.TestCase{ + {ID: "hidden-current", Visibility: models.TestHidden, ExpectedOutput: "current-secret"}, + }, + } + legacy := models.Problem{ + StarterFiles: map[models.Language][]models.FileNode{ + models.LangGo: {{ID: "go-stale", Name: "old.go"}}, + models.LangPython: {{ID: "python-legacy", Name: "main.py"}}, + }, + BoilerplateRefs: map[models.Language]string{ + models.LangGo: "go-stale-ref", + models.LangPython: "python-legacy-ref", + }, + TestCases: []models.TestCase{ + {ID: "public-current", Visibility: models.TestPublic, ExpectedOutput: "stale"}, + {ID: "public-legacy", Visibility: models.TestPublic, ExpectedOutput: "legacy"}, + {ID: "hidden-in-public", Visibility: models.TestHidden, ExpectedOutput: "legacy-secret"}, + }, + HiddenTestCases: []models.TestCase{ + {ID: "hidden-current", ExpectedOutput: "stale-secret"}, + {ID: "hidden-legacy", ExpectedOutput: "second-legacy-secret"}, + }, + } + + mergeLegacyProblemSatellites(&problem, legacy) + + if got := problem.StarterFiles[models.LangGo][0].ID; got != "go-current" { + t.Fatalf("materialized Go starter overwritten with %q", got) + } + if got := problem.StarterFiles[models.LangPython][0].ID; got != "python-legacy" { + t.Fatalf("missing legacy Python starter, got %q", got) + } + if got := problem.BoilerplateRefs[models.LangGo]; got != "go-current-ref" { + t.Fatalf("materialized Go reference overwritten with %q", got) + } + if got := problem.BoilerplateRefs[models.LangPython]; got != "python-legacy-ref" { + t.Fatalf("missing legacy Python reference, got %q", got) + } + if len(problem.TestCases) != 2 || problem.TestCases[1].ID != "public-legacy" { + t.Fatalf("public cases were not merged by ID: %+v", problem.TestCases) + } + if len(problem.HiddenTestCases) != 3 { + t.Fatalf("hidden cases = %+v, want current plus two legacy cases", problem.HiddenTestCases) + } + for _, testCase := range problem.HiddenTestCases { + if testCase.Visibility != models.TestHidden { + t.Fatalf("hidden case %q has visibility %q", testCase.ID, testCase.Visibility) + } + } +} diff --git a/interviewhub-backend-go/internal/store/postgres_runtime.go b/interviewhub-backend-go/internal/store/postgres_runtime.go new file mode 100644 index 00000000..ce709c1a --- /dev/null +++ b/interviewhub-backend-go/internal/store/postgres_runtime.go @@ -0,0 +1,15 @@ +package store + +import ( + "context" + "time" +) + +const postgresRuntimeOperationTimeout = 5 * time.Second + +// postgresRuntimeContext bounds repository calls whose legacy interfaces do +// not carry the incoming request context. This prevents both pool acquisition +// and PostgreSQL execution from waiting indefinitely during an outage. +func postgresRuntimeContext() (context.Context, context.CancelFunc) { + return context.WithTimeout(context.Background(), postgresRuntimeOperationTimeout) +} diff --git a/interviewhub-backend-go/internal/store/postgres_runtime_test.go b/interviewhub-backend-go/internal/store/postgres_runtime_test.go new file mode 100644 index 00000000..ba841823 --- /dev/null +++ b/interviewhub-backend-go/internal/store/postgres_runtime_test.go @@ -0,0 +1,55 @@ +package store + +import ( + "go/ast" + "go/parser" + "go/token" + "testing" + "time" +) + +func TestPostgresRuntimeContextIsBounded(t *testing.T) { + started := time.Now() + ctx, cancel := postgresRuntimeContext() + deadline, ok := ctx.Deadline() + if !ok { + cancel() + t.Fatal("runtime PostgreSQL context has no deadline") + } + if remaining := deadline.Sub(started); remaining <= 0 || remaining > postgresRuntimeOperationTimeout+time.Second { + cancel() + t.Fatalf("runtime PostgreSQL deadline is %s from start, want at most %s", remaining, postgresRuntimeOperationTimeout) + } + cancel() + select { + case <-ctx.Done(): + default: + t.Fatal("cancel did not release the runtime PostgreSQL context") + } +} + +func TestRuntimePostgresFilesDoNotUseUnboundedDatabaseCalls(t *testing.T) { + for _, filename := range []string{"postgres.go", "postgres_problems.go"} { + fileSet := token.NewFileSet() + parsed, err := parser.ParseFile(fileSet, filename, nil, 0) + if err != nil { + t.Fatalf("parse %s: %v", filename, err) + } + ast.Inspect(parsed, func(node ast.Node) bool { + call, ok := node.(*ast.CallExpr) + if !ok { + return true + } + selector, ok := call.Fun.(*ast.SelectorExpr) + if !ok { + return true + } + switch selector.Sel.Name { + case "Begin", "Exec", "Query", "QueryRow": + position := fileSet.Position(call.Pos()) + t.Errorf("%s uses unbounded database call %s; use its Context variant", position, selector.Sel.Name) + } + return true + }) + } +} diff --git a/interviewhub-backend-go/internal/store/problem_errors.go b/interviewhub-backend-go/internal/store/problem_errors.go new file mode 100644 index 00000000..944e8592 --- /dev/null +++ b/interviewhub-backend-go/internal/store/problem_errors.go @@ -0,0 +1,14 @@ +package store + +import "errors" + +// ErrProblemReferenced indicates that deleting/resetting catalog rows would +// orphan immutable submission history. Callers should surface this as a +// conflict, not as a generic storage outage. +var ErrProblemReferenced = errors.New("problem is referenced by persisted data") + +// ErrProblemWorkspaceReferenced protects candidate-owned drafts when a +// catalog reset would otherwise delete their parent problem via ON DELETE +// CASCADE. Reset callers must resolve or explicitly migrate those drafts +// before removing the obsolete problem. +var ErrProblemWorkspaceReferenced = errors.New("problem is referenced by candidate workspaces") diff --git a/interviewhub-backend-go/internal/store/refresh_family_query_test.go b/interviewhub-backend-go/internal/store/refresh_family_query_test.go new file mode 100644 index 00000000..78eb82ba --- /dev/null +++ b/interviewhub-backend-go/internal/store/refresh_family_query_test.go @@ -0,0 +1,51 @@ +package store + +import ( + "os" + "strings" + "testing" +) + +// This is a static query-plan guard for environments where the unit suite has +// no PostgreSQL server. Wrapping family_id in COALESCE makes the ordinary +// refresh_tokens_family_idx unusable unless a matching expression index also +// exists. Migration 004 made family_id NOT NULL, so runtime queries must use +// the indexed column directly. +func TestRefreshFamilyQueriesUseIndexedNotNullColumn(t *testing.T) { + sourceBytes, err := os.ReadFile("postgres.go") + if err != nil { + t.Fatalf("read postgres.go: %v", err) + } + source := string(sourceBytes) + if strings.Contains(source, "COALESCE(family_id, id)") { + t.Fatal("refresh-family query wraps family_id in COALESCE and bypasses refresh_tokens_family_idx") + } + + requiredQueries := []string{ + "SELECT id, user_id, family_id, token_hash", + "SELECT family_id FROM refresh_tokens WHERE token_hash = $1", + "WHERE family_id = $1", + } + for _, queryFragment := range requiredQueries { + if !strings.Contains(source, queryFragment) { + t.Errorf("postgres refresh-token queries are missing direct indexed fragment %q", queryFragment) + } + } + if count := strings.Count(source, "WHERE family_id = $1"); count < 2 { + t.Errorf("direct family_id predicate count=%d, want at least generation-count and revocation queries", count) + } + + migrationBytes, err := migrationFS.ReadFile("migrations/004_problem_structure.sql") + if err != nil { + t.Fatalf("read family migration: %v", err) + } + migration := string(migrationBytes) + for _, invariant := range []string{ + "ALTER TABLE refresh_tokens ALTER COLUMN family_id SET NOT NULL", + "CREATE INDEX IF NOT EXISTS refresh_tokens_family_idx ON refresh_tokens (family_id)", + } { + if !strings.Contains(migration, invariant) { + t.Errorf("refresh-token family migration is missing invariant %q", invariant) + } + } +} diff --git a/interviewhub-backend-go/internal/store/repository.go b/interviewhub-backend-go/internal/store/repository.go index fe4873b2..fc58b2e2 100644 --- a/interviewhub-backend-go/internal/store/repository.go +++ b/interviewhub-backend-go/internal/store/repository.go @@ -1,40 +1,143 @@ package store import ( + "errors" "time" "github.com/interviewhub/backend/internal/models" ) +// ErrConflict marks a persistence uniqueness or optimistic-write conflict that +// callers may safely translate to HTTP 409 without exposing adapter details. +var ErrConflict = errors.New("persistence conflict") + type UserRepository interface { GetUserByID(id string) (models.User, bool) GetUserByEmail(email string) (models.User, bool) - CreateUser(user models.User) models.User + CreateUser(user models.User) (models.User, error) } type ProblemRepository interface { GetAllProblems() []models.Problem GetProblemByID(id string) (models.Problem, bool) - SaveProblem(problem models.Problem) models.Problem - DeleteProblem(id string) bool - ResetProblemsToDefaults() []models.Problem + SaveProblem(problem models.Problem) (models.Problem, error) + // SaveProblemIfUnchanged replaces a problem document only when the + // persisted revision still matches expectedUpdatedAt. Implementations must + // update the parent row and every satellite collection atomically. + SaveProblemIfUnchanged(problem models.Problem, expectedUpdatedAt time.Time) (models.Problem, error) + DeleteProblem(id string) (bool, error) + ResetProblemsToDefaults() ([]models.Problem, error) } type WorkspaceRepository interface { GetWorkspace(userID, problemID string, language models.Language) (models.Workspace, bool) - SaveWorkspace(workspace models.Workspace) models.Workspace - DeleteWorkspace(userID, problemID string, language models.Language) bool + SaveWorkspace(workspace models.Workspace) (models.Workspace, error) + DeleteWorkspace(userID, problemID string, language models.Language) (bool, error) +} + +// The following additive capabilities preserve legacy boolean reads for +// modules that have not yet migrated while allowing auth and workspace paths +// to distinguish a missing row from an unavailable/corrupt datastore. Both +// production and in-memory adapters implement every capability below. +type ErrorAwareUserLookup interface { + LookupUserByID(id string) (models.User, bool, error) + LookupUserByEmail(email string) (models.User, bool, error) +} + +type ErrorAwareProblemExistence interface { + ProblemExists(id string) (bool, error) +} + +// ErrorAwareProblemReader exposes complete catalog reads without collapsing a +// datastore failure into an empty list or a false "not found" result. Problem +// documents span the problems, problem_boilerplates, and problem_test_cases +// tables, so production adapters must return an error if any part cannot be +// read or decoded. +type ErrorAwareProblemReader interface { + LookupAllProblems() ([]models.Problem, error) + LookupProblemByID(id string) (models.Problem, bool, error) +} + +type ErrorAwareWorkspaceLookup interface { + LookupWorkspace(userID, problemID string, language models.Language) (models.Workspace, bool, error) +} + +type ErrorAwareAuthCodeConsumer interface { + ConsumeAuthCodeWithError(codeHash string) (userID string, ok bool, err error) +} + +type ErrorAwareRefreshTokenRevoker interface { + // RevokeRefreshTokenFamilyByHash must serialize with RotateRefreshToken for + // the resolved family so logout cannot miss a concurrently inserted child. + RevokeRefreshTokenFamilyByHash(tokenHash string, now time.Time) (revoked bool, err error) +} + +// OAuthIdentityRepository binds external accounts to stable provider subjects. +// It is intentionally additive so non-auth repository adapters can migrate +// without weakening the production authentication path. +type OAuthIdentityRepository interface { + LookupOAuthIdentity(provider, subject string) (models.User, bool, error) + LinkOAuthIdentity(provider, subject, userID, providerEmail string) error + CreateOAuthUserWithIdentity(user models.User, provider, subject, providerEmail string) (models.User, error) } type SubmissionRepository interface { - GetAllSubmissions() []models.Submission - GetSubmissionsByUserID(userID string) []models.Submission - SaveSubmission(submission models.Submission) models.Submission + // LookupSubmissionPage must apply owner, problem, cursor, ordering, and + // limit predicates in the datastore. Production implementations must not + // load complete JSON submission documents to build list rows. + LookupSubmissionPage(query SubmissionPageQuery) (SubmissionPageResult, error) + // LookupSubmissionByID performs the ownership predicate in the same indexed + // lookup as the ID predicate. An empty OwnerUserID is the admin scope. + LookupSubmissionByID(id, ownerUserID string) (models.Submission, bool, error) + AggregateSubmissionProgress(userID string) (models.UserProgress, error) + SaveSubmission(submission models.Submission) (models.Submission, error) +} + +const ( + DefaultSubmissionPageSize = 50 + MaxSubmissionPageSize = 100 +) + +// SubmissionPageQuery is the datastore-level keyset pagination contract. +// Cursor fields represent the last row returned by the previous page. +type SubmissionPageQuery struct { + OwnerUserID string + ProblemID string + BeforeCreatedAt time.Time + BeforeID string + Limit int +} + +type SubmissionPageResult struct { + Items []models.SubmissionSummary + HasMore bool } +// RefreshTokenRotationOutcome distinguishes invalid credentials from storage +// failures. Services can safely map these outcomes to authentication errors +// without guessing whether a transaction committed. +type RefreshTokenRotationOutcome string + +const ( + RefreshTokenRotated RefreshTokenRotationOutcome = "rotated" + RefreshTokenNotFound RefreshTokenRotationOutcome = "not_found" + RefreshTokenExpired RefreshTokenRotationOutcome = "expired" + RefreshTokenReused RefreshTokenRotationOutcome = "reused" + RefreshTokenGenerationLimit RefreshTokenRotationOutcome = "generation_limit" +) + type RefreshTokenRepository interface { - SaveRefreshToken(token models.RefreshToken) + SaveRefreshToken(token models.RefreshToken) error GetRefreshTokenByHash(tokenHash string) (models.RefreshToken, bool) + // RotateRefreshToken atomically consumes tokenHash and persists replacement. + // The repository derives replacement.UserID and FamilyID from the locked old + // token so callers cannot accidentally move a credential between families. + // Replacements inherit the family's existing expiry instead of extending it, + // and exceeding the bounded generation count revokes the family and returns + // RefreshTokenGenerationLimit. + // Replaying a revoked token revokes every active token in that family before + // returning RefreshTokenReused. + RotateRefreshToken(tokenHash string, replacement models.RefreshToken, now time.Time) (models.RefreshToken, RefreshTokenRotationOutcome, error) RevokeRefreshToken(id string) bool RevokeRefreshTokenFamily(familyID string) bool } @@ -44,9 +147,9 @@ type AuthCodeRepository interface { ConsumeAuthCode(codeHash string) (userID string, ok bool) } -// Repository is the application's persistence boundary. Both the local JSON -// store and the PostgreSQL adapter implement it so services remain independent -// of the deployment datastore. +// Repository is the application's persistence boundary. Both the isolated +// in-memory test adapter and PostgreSQL implement it so services remain +// independent of the deployment datastore. type Repository interface { UserRepository ProblemRepository diff --git a/interviewhub-backend-go/internal/store/seed.go b/interviewhub-backend-go/internal/store/seed.go index 30d54f46..343ef091 100644 --- a/interviewhub-backend-go/internal/store/seed.go +++ b/interviewhub-backend-go/internal/store/seed.go @@ -2,9 +2,11 @@ package store import ( "context" + "crypto/sha256" + "database/sql" "errors" "fmt" - "path/filepath" + "path" "sort" "strings" "time" @@ -14,8 +16,8 @@ import ( "golang.org/x/crypto/bcrypt" ) -// SeedOptions controls what the portable seed/migrate tooling writes. -// The same options are used by server startup and `go run ./cmd/seed`. +// SeedOptions controls what the explicit migrate/seed command writes. The API +// server never invokes this path during startup. type SeedOptions struct { // SkipMigrate skips applying embedded SQL migrations. SkipMigrate bool @@ -34,21 +36,18 @@ type SeedOptions struct { ResetProblems bool } -// DefaultSeedOptions is used on server boot: migrate + fill empty tables only. -func DefaultSeedOptions() SeedOptions { - return SeedOptions{ - SeedUsers: true, - SeedProblems: true, - } +type migrationManifestEntry struct { + version string + checksum string + payload []byte } -// Migrate applies every embedded SQL file under migrations/ in lexical order. -// Statements are idempotent (IF NOT EXISTS / IF NOT EXISTS column patterns). -func (s *PostgresStore) Migrate(ctx context.Context) error { +func embeddedMigrationManifest() ([]migrationManifestEntry, error) { entries, err := migrationFS.ReadDir("migrations") if err != nil { - return err + return nil, fmt.Errorf("read embedded migrations: %w", err) } + files := make([]string, 0, len(entries)) for _, entry := range entries { if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".sql") { @@ -57,18 +56,117 @@ func (s *PostgresStore) Migrate(ctx context.Context) error { files = append(files, entry.Name()) } sort.Strings(files) + if len(files) == 0 { + return nil, errors.New("no embedded SQL migrations found") + } + + manifest := make([]migrationManifestEntry, 0, len(files)) for _, name := range files { - payload, err := migrationFS.ReadFile(filepath.Join("migrations", name)) + payload, err := migrationFS.ReadFile(path.Join("migrations", name)) if err != nil { - return fmt.Errorf("read migration %s: %w", name, err) + return nil, fmt.Errorf("read migration %s: %w", name, err) + } + manifest = append(manifest, migrationManifestEntry{ + version: name, + checksum: fmt.Sprintf("%x", sha256.Sum256(payload)), + payload: payload, + }) + } + return manifest, nil +} + +func validateMigrationState(manifest []migrationManifestEntry, applied map[string]string) error { + for _, migration := range manifest { + checksum, ok := applied[migration.version] + if !ok { + return fmt.Errorf("required migration %s has not been applied", migration.version) } - if _, err := s.db.ExecContext(ctx, string(payload)); err != nil { - return fmt.Errorf("apply migration %s: %w", name, err) + if checksum != migration.checksum { + return fmt.Errorf("migration %s checksum does not match the embedded migration", migration.version) } } return nil } +// ValidateMigrations verifies that PostgreSQL has every migration required by +// this binary and that applied SQL has not drifted from the embedded source. +// It never mutates schema; deployment tooling must run Migrate first. +func (s *PostgresStore) ValidateMigrations(ctx context.Context) error { + manifest, err := embeddedMigrationManifest() + if err != nil { + return err + } + + rows, err := s.db.QueryContext(ctx, `SELECT version, checksum FROM schema_migrations`) + if err != nil { + return fmt.Errorf("read schema migration state: %w", err) + } + defer rows.Close() + + applied := make(map[string]string, len(manifest)) + for rows.Next() { + var version string + var checksum string + if err := rows.Scan(&version, &checksum); err != nil { + return fmt.Errorf("scan schema migration state: %w", err) + } + applied[version] = checksum + } + if err := rows.Err(); err != nil { + return fmt.Errorf("iterate schema migration state: %w", err) + } + return validateMigrationState(manifest, applied) +} + +// Migrate applies every embedded SQL file under migrations/ in lexical order. +// Statements are idempotent (IF NOT EXISTS / IF NOT EXISTS column patterns). +func (s *PostgresStore) Migrate(ctx context.Context) error { + manifest, err := embeddedMigrationManifest() + if err != nil { + return err + } + + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("begin migration transaction: %w", err) + } + defer func() { _ = tx.Rollback() }() + if _, err := tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock(hashtext('judgemycode_schema_migrations'))`); err != nil { + return fmt.Errorf("acquire migration lock: %w", err) + } + if _, err := tx.ExecContext(ctx, ` + CREATE TABLE IF NOT EXISTS schema_migrations ( + version TEXT PRIMARY KEY, + checksum TEXT NOT NULL, + applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + )`); err != nil { + return fmt.Errorf("create schema_migrations: %w", err) + } + + for _, migration := range manifest { + var appliedChecksum string + err = tx.QueryRowContext(ctx, `SELECT checksum FROM schema_migrations WHERE version = $1`, migration.version).Scan(&appliedChecksum) + switch { + case err == nil && appliedChecksum != migration.checksum: + return fmt.Errorf("migration %s changed after it was applied", migration.version) + case err == nil: + continue + case !errors.Is(err, sql.ErrNoRows): + return fmt.Errorf("read migration state for %s: %w", migration.version, err) + } + if _, err := tx.ExecContext(ctx, string(migration.payload)); err != nil { + return fmt.Errorf("apply migration %s: %w", migration.version, err) + } + if _, err := tx.ExecContext(ctx, `INSERT INTO schema_migrations (version, checksum) VALUES ($1, $2)`, migration.version, migration.checksum); err != nil { + return fmt.Errorf("record migration %s: %w", migration.version, err) + } + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit migrations: %w", err) + } + return nil +} + // Seed populates default users and/or the problem catalog according to opts. // Safe to re-run: empty-table seeding is a no-op when rows already exist unless // ForceUsers / SyncProblems / ResetProblems is set. @@ -92,20 +190,20 @@ func (s *PostgresStore) Seed(ctx context.Context, opts SeedOptions) (SeedReport, switch { case opts.ResetProblems: - problems := s.ResetProblemsToDefaults() - if len(problems) == 0 { - return report, errors.New("failed to reset problem catalog") + problems, err := s.ResetProblemsToDefaults() + if err != nil { + return report, err } report.ProblemsUpserted = len(problems) report.ProblemsReset = true case opts.SyncProblems: - n, err := s.syncSeedProblems() + n, err := s.syncSeedProblems(ctx) if err != nil { return report, err } report.ProblemsUpserted = n case opts.SeedProblems: - n, err := s.seedProblemsIfEmpty() + n, err := s.seedProblemsIfEmpty(ctx) if err != nil { return report, err } @@ -196,23 +294,64 @@ func (s *PostgresStore) seedUsers(ctx context.Context, force bool) (int, error) return upserted, nil } -func (s *PostgresStore) seedProblemsIfEmpty() (int, error) { +func (s *PostgresStore) seedProblemsIfEmpty(ctx context.Context) (int, error) { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return 0, fmt.Errorf("begin empty problem catalog seed: %w", err) + } + defer func() { _ = tx.Rollback() }() + if err := lockProblemCatalogMutation(ctx, tx); err != nil { + return 0, err + } + var count int - if err := s.db.QueryRow("SELECT COUNT(*) FROM problems").Scan(&count); err != nil { + if err := tx.QueryRowContext(ctx, "SELECT COUNT(*) FROM problems").Scan(&count); err != nil { return 0, err } if count > 0 { + if err := tx.Commit(); err != nil { + return 0, fmt.Errorf("commit empty problem catalog check: %w", err) + } return 0, nil } - return s.syncSeedProblems() + upserted, err := syncSeedProblemsTx(ctx, tx) + if err != nil { + return upserted, err + } + if err := tx.Commit(); err != nil { + return upserted, fmt.Errorf("commit initial problem catalog seed: %w", err) + } + return upserted, nil } -func (s *PostgresStore) syncSeedProblems() (int, error) { +func (s *PostgresStore) syncSeedProblems(ctx context.Context) (int, error) { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return 0, fmt.Errorf("begin problem catalog sync: %w", err) + } + defer func() { _ = tx.Rollback() }() + if err := lockProblemCatalogMutation(ctx, tx); err != nil { + return 0, err + } + upserted, err := syncSeedProblemsTx(ctx, tx) + if err != nil { + return upserted, err + } + if err := tx.Commit(); err != nil { + return upserted, fmt.Errorf("commit problem catalog sync: %w", err) + } + return upserted, nil +} + +func syncSeedProblemsTx(ctx context.Context, tx *sql.Tx) (int, error) { upserted := 0 for _, problem := range data.GetSeedProblems() { - saved := s.SaveProblem(problem) + saved, err := saveProblemTx(ctx, tx, problem, nil) + if err != nil { + return upserted, fmt.Errorf("seed problem %s: %w", problem.ID, err) + } if saved.ID == "" { - return upserted, fmt.Errorf("failed to seed problem %s", problem.ID) + return upserted, fmt.Errorf("seed problem %s: persistence returned an empty ID", problem.ID) } upserted++ } diff --git a/interviewhub-backend-go/internal/store/seed_migrations_test.go b/interviewhub-backend-go/internal/store/seed_migrations_test.go new file mode 100644 index 00000000..b366917c --- /dev/null +++ b/interviewhub-backend-go/internal/store/seed_migrations_test.go @@ -0,0 +1,109 @@ +package store + +import ( + "crypto/sha256" + "fmt" + "sort" + "testing" +) + +// These checksums are a review tripwire for migrations that can already exist +// in developer or deployed databases. Schema changes must be appended as a new +// numbered migration; changing an entry here requires the same explicit review +// as rewriting migration history. +var publishedMigrationChecksums = map[string]string{ + "001_initial.sql": "7c637802ac476896c5b71f16bc7978a6ef3d42ae395357c9b1a50a73b473da61", + "002_production_schema.sql": "7b2461b1d1682af763c838f1ac7edfae8c67a1ddc3e628cba740d47b4ef79a8f", + "003_oauth_fields.sql": "39e400de6586fd4b8153b5ef371ae05474c19bb683068afd961d6478fd400e5c", + "004_problem_structure.sql": "4c8f83f8b400261757e3a133c472f9250109233843aa5fd62a8cfd5c1f853935", + "005_problem_test_case_identity.sql": "bf276befc480bbb53aeed430a5a26a6ec6f206e191adf9a3d9c9a9d41507875b", + "006_workspace_revisions.sql": "983602059550eb67efa103b19f525f881480c2cb1988c6ca899074b38bb43507", + "007_auth_artifact_retention.sql": "f71b082e6a12bf6288986b9a328aba2c635ff3e8b9785a7987982ed8e56d05e0", + "008_oauth_identities.sql": "70edd7638b479520813ddd034de700267f9fdfe379e78f4946128014af695a09", + "009_history_and_delete_safety.sql": "fcc2b74fae517aa75370752398dab5f1271ff7db931daf812d706fddf6c8cd4c", +} + +func TestEmbeddedMigrationManifestIsOrderedAndChecksummed(t *testing.T) { + manifest, err := embeddedMigrationManifest() + if err != nil { + t.Fatalf("embeddedMigrationManifest() error = %v", err) + } + if len(manifest) == 0 { + t.Fatal("embeddedMigrationManifest() returned no migrations") + } + + versions := make([]string, 0, len(manifest)) + for _, migration := range manifest { + versions = append(versions, migration.version) + wantChecksum := fmt.Sprintf("%x", sha256.Sum256(migration.payload)) + if migration.checksum != wantChecksum { + t.Fatalf("migration %s checksum = %q, want %q", migration.version, migration.checksum, wantChecksum) + } + } + if !sort.StringsAreSorted(versions) { + t.Fatalf("migration versions are not sorted: %v", versions) + } +} + +func TestPublishedMigrationsRemainImmutable(t *testing.T) { + manifest, err := embeddedMigrationManifest() + if err != nil { + t.Fatalf("embeddedMigrationManifest() error = %v", err) + } + + actual := make(map[string]string, len(manifest)) + for _, migration := range manifest { + actual[migration.version] = migration.checksum + } + for version, expected := range publishedMigrationChecksums { + checksum, ok := actual[version] + if !ok { + t.Errorf("published migration %s was removed", version) + continue + } + if checksum != expected { + t.Errorf("published migration %s changed: checksum = %s, want %s; add a new numbered migration instead", version, checksum, expected) + } + } +} + +func TestValidateMigrationState(t *testing.T) { + manifest, err := embeddedMigrationManifest() + if err != nil { + t.Fatalf("embeddedMigrationManifest() error = %v", err) + } + applied := make(map[string]string, len(manifest)) + for _, migration := range manifest { + applied[migration.version] = migration.checksum + } + + t.Run("all required migrations match", func(t *testing.T) { + if err := validateMigrationState(manifest, applied); err != nil { + t.Fatalf("validateMigrationState() error = %v", err) + } + }) + + t.Run("required migration is missing", func(t *testing.T) { + incomplete := cloneMigrationState(applied) + delete(incomplete, manifest[len(manifest)-1].version) + if err := validateMigrationState(manifest, incomplete); err == nil { + t.Fatal("validateMigrationState() error = nil, want missing migration error") + } + }) + + t.Run("recorded checksum has drifted", func(t *testing.T) { + drifted := cloneMigrationState(applied) + drifted[manifest[0].version] = "unexpected-checksum" + if err := validateMigrationState(manifest, drifted); err == nil { + t.Fatal("validateMigrationState() error = nil, want checksum drift error") + } + }) +} + +func cloneMigrationState(source map[string]string) map[string]string { + clone := make(map[string]string, len(source)) + for version, checksum := range source { + clone[version] = checksum + } + return clone +} diff --git a/interviewhub-backend-go/internal/store/store.go b/interviewhub-backend-go/internal/store/store.go index 761128b5..c9ab5687 100644 --- a/interviewhub-backend-go/internal/store/store.go +++ b/interviewhub-backend-go/internal/store/store.go @@ -1,140 +1,79 @@ package store import ( - "encoding/json" + "errors" "fmt" - "os" - "path/filepath" + "sort" "strings" "sync" "time" - "github.com/interviewhub/backend/internal/config" "github.com/interviewhub/backend/internal/data" "github.com/interviewhub/backend/internal/models" "golang.org/x/crypto/bcrypt" ) -// Store is the in-memory/JSON adapter used by unit tests only. -// The API server requires PostgreSQL (see cmd/server and store.NewPostgres). +var memorySeedTime = time.Date(2026, time.January, 1, 0, 0, 0, 0, time.UTC) + +// Store is a fresh, process-local Repository implementation for unit tests. +// It deliberately has no singleton and performs no filesystem I/O, so every +// test owns its state and can run safely in parallel. Production uses +// PostgresStore (see cmd/server and NewPostgres). type Store struct { - mu sync.RWMutex - users map[string]models.User - problems map[string]models.Problem - submissions map[string]models.Submission - workspaces map[string]models.Workspace - refreshTokens map[string]models.RefreshToken - authCodes map[string]authCodeRecord - usersFile string - problemsFile string - submissionsFile string - workspacesFile string - refreshTokensFile string -} + mu sync.RWMutex + users map[string]models.User + problems map[string]models.Problem + submissions map[string]models.Submission + workspaces map[string]models.Workspace + refreshTokens map[string]models.RefreshToken + authCodes map[string]authCodeRecord + oauthIdentities map[string]string -type authCodeRecord struct { - UserID string - ExpiresAt time.Time - Used bool + // refreshTokenWriteHook is intentionally unexported. Store tests use it to + // prove that a failed replacement write cannot partially consume the old + // token; production adapters surface their native storage errors instead. + refreshTokenWriteHook func(models.RefreshToken) error } var ( - instance *Store - once sync.Once + _ Repository = (*Store)(nil) + _ ErrorAwareUserLookup = (*Store)(nil) + _ ErrorAwareProblemExistence = (*Store)(nil) + _ ErrorAwareProblemReader = (*Store)(nil) + _ ErrorAwareWorkspaceLookup = (*Store)(nil) + _ ErrorAwareAuthCodeConsumer = (*Store)(nil) + _ ErrorAwareRefreshTokenRevoker = (*Store)(nil) + _ OAuthIdentityRepository = (*Store)(nil) ) -// GetInstance returns the singleton Store instance -func GetInstance(cfg *config.Config) *Store { - once.Do(func() { - _ = os.MkdirAll(cfg.DatabaseDir, 0755) - _ = os.MkdirAll(cfg.SandboxTempDir, 0755) - - instance = &Store{ - users: make(map[string]models.User), - problems: make(map[string]models.Problem), - submissions: make(map[string]models.Submission), - workspaces: make(map[string]models.Workspace), - refreshTokens: make(map[string]models.RefreshToken), - authCodes: make(map[string]authCodeRecord), - usersFile: filepath.Join(cfg.DatabaseDir, "users.json"), - problemsFile: filepath.Join(cfg.DatabaseDir, "problems.json"), - submissionsFile: filepath.Join(cfg.DatabaseDir, "submissions.json"), - workspacesFile: filepath.Join(cfg.DatabaseDir, "workspaces.json"), - refreshTokensFile: filepath.Join(cfg.DatabaseDir, "refresh_tokens.json"), - } - instance.loadData() - }) - return instance +type authCodeRecord struct { + UserID string + ExpiresAt time.Time + Used bool } -func (s *Store) loadData() { - s.mu.Lock() - defer s.mu.Unlock() - - // 1. Users - if dataBytes, err := os.ReadFile(s.usersFile); err == nil { - var userList []models.User - if err := json.Unmarshal(dataBytes, &userList); err == nil { - for _, u := range userList { - s.users[u.ID] = u - } - } - } - if len(s.users) == 0 { - s.seedUsersLocked() - } - - // 2. Problems - if dataBytes, err := os.ReadFile(s.problemsFile); err == nil { - var probList []models.Problem - if err := json.Unmarshal(dataBytes, &probList); err == nil { - for _, p := range probList { - s.problems[p.ID] = p - } - } - } - if len(s.problems) == 0 { - s.seedProblemsLocked() - } - - // 3. Submissions - if dataBytes, err := os.ReadFile(s.submissionsFile); err == nil { - var subList []models.Submission - if err := json.Unmarshal(dataBytes, &subList); err == nil { - for _, sub := range subList { - s.submissions[sub.ID] = sub - } - } - } - - // 4. Workspaces - if dataBytes, err := os.ReadFile(s.workspacesFile); err == nil { - var workspaceList []models.Workspace - if err := json.Unmarshal(dataBytes, &workspaceList); err == nil { - for _, workspace := range workspaceList { - s.workspaces[workspaceKey(workspace.UserID, workspace.ProblemID, workspace.Language)] = workspace - } - } +// NewMemoryStore returns an isolated Repository populated with the same demo +// users and problem catalog used by local development. No state is shared with +// any previous invocation. +func NewMemoryStore() *Store { + store := &Store{ + users: make(map[string]models.User), + problems: make(map[string]models.Problem), + submissions: make(map[string]models.Submission), + workspaces: make(map[string]models.Workspace), + refreshTokens: make(map[string]models.RefreshToken), + authCodes: make(map[string]authCodeRecord), + oauthIdentities: make(map[string]string), } - - if dataBytes, err := os.ReadFile(s.refreshTokensFile); err == nil { - var tokenList []models.RefreshToken - if err := json.Unmarshal(dataBytes, &tokenList); err == nil { - for _, token := range tokenList { - s.refreshTokens[token.ID] = token - } - } - } - - fmt.Printf("[Store] Engine initialized. Loaded %d users, %d problems, %d submissions.\n", - len(s.users), len(s.problems), len(s.submissions)) + store.seedUsersLocked() + store.seedProblemsLocked() + return store } func (s *Store) seedUsersLocked() { adminHash, _ := bcrypt.GenerateFromPassword([]byte("admin123"), bcrypt.DefaultCost) candHash, _ := bcrypt.GenerateFromPassword([]byte("pass123"), bcrypt.DefaultCost) - now := time.Now() users := []models.User{ { ID: "usr_admin_01", @@ -142,7 +81,8 @@ func (s *Store) seedUsersLocked() { Email: "admin@interviewhub.io", PasswordHash: string(adminHash), Role: models.RoleAdmin, - CreatedAt: now, + AuthProvider: "local", + CreatedAt: memorySeedTime, }, { ID: "usr_cand_01", @@ -150,81 +90,75 @@ func (s *Store) seedUsersLocked() { Email: "candidate@google.com", PasswordHash: string(candHash), Role: models.RoleCandidate, - CreatedAt: now, + AuthProvider: "local", + CreatedAt: memorySeedTime, }, } for _, u := range users { s.users[u.ID] = u } - s.saveUsersLocked() } func (s *Store) seedProblemsLocked() { for _, p := range data.GetSeedProblems() { + normalizeProblemForPersistence(&p) + p.CreatedAt = memorySeedTime + p.UpdatedAt = memorySeedTime s.problems[p.ID] = p } - s.saveProblemsLocked() } -// ResetProblemsToDefaults resets the problem catalog to initial seeds -func (s *Store) ResetProblemsToDefaults() []models.Problem { +// ResetProblemsToDefaults updates seed problems in place. Obsolete problems +// are removed only when doing so cannot orphan submission history or delete a +// candidate-owned workspace. +func (s *Store) ResetProblemsToDefaults() ([]models.Problem, error) { s.mu.Lock() defer s.mu.Unlock() - s.problems = make(map[string]models.Problem) - s.seedProblemsLocked() - - list := make([]models.Problem, 0, len(s.problems)) - for _, p := range s.problems { - list = append(list, p) - } - return list -} - -func (s *Store) saveUsersLocked() { - list := make([]models.User, 0, len(s.users)) - for _, u := range s.users { - list = append(list, u) + seeds := data.GetSeedProblems() + seedIDs := make(map[string]struct{}, len(seeds)) + for _, problem := range seeds { + seedIDs[problem.ID] = struct{}{} } - bytes, _ := json.MarshalIndent(list, "", " ") - _ = os.WriteFile(s.usersFile, bytes, 0644) -} - -func (s *Store) saveProblemsLocked() { - list := make([]models.Problem, 0, len(s.problems)) - for _, p := range s.problems { - list = append(list, p) + obsoleteIDs := make(map[string]struct{}) + for id := range s.problems { + if _, retained := seedIDs[id]; !retained { + obsoleteIDs[id] = struct{}{} + } } - bytes, _ := json.MarshalIndent(list, "", " ") - _ = os.WriteFile(s.problemsFile, bytes, 0644) -} -func (s *Store) saveSubmissionsLocked() { - list := make([]models.Submission, 0, len(s.submissions)) - for _, sub := range s.submissions { - list = append(list, sub) + for _, submission := range s.submissions { + if _, obsolete := obsoleteIDs[submission.ProblemID]; obsolete { + return nil, fmt.Errorf("%w: submission %s references %s", ErrProblemReferenced, submission.ID, submission.ProblemID) + } } - bytes, _ := json.MarshalIndent(list, "", " ") - _ = os.WriteFile(s.submissionsFile, bytes, 0644) -} - -func (s *Store) saveWorkspacesLocked() { - list := make([]models.Workspace, 0, len(s.workspaces)) for _, workspace := range s.workspaces { - list = append(list, workspace) + if _, obsolete := obsoleteIDs[workspace.ProblemID]; obsolete { + return nil, fmt.Errorf("%w: workspace for %s references %s", ErrProblemWorkspaceReferenced, workspace.UserID, workspace.ProblemID) + } } - bytes, _ := json.MarshalIndent(list, "", " ") - _ = os.WriteFile(s.workspacesFile, bytes, 0644) -} -func (s *Store) saveRefreshTokensLocked() { - list := make([]models.RefreshToken, 0, len(s.refreshTokens)) - for _, token := range s.refreshTokens { - list = append(list, token) + now := nextProblemUpdatedAt(time.Time{}) + list := make([]models.Problem, 0, len(seeds)) + for _, problem := range seeds { + normalizeProblemForPersistence(&problem) + if existing, ok := s.problems[problem.ID]; ok { + problem.CreatedAt = existing.CreatedAt + problem.UpdatedAt = nextProblemUpdatedAt(existing.UpdatedAt) + } else { + problem.CreatedAt = now + problem.UpdatedAt = now + } + s.problems[problem.ID] = problem + list = append(list, problem) + } + for id := range obsoleteIDs { + delete(s.problems, id) } - bytes, _ := json.MarshalIndent(list, "", " ") - _ = os.WriteFile(s.refreshTokensFile, bytes, 0600) + + sort.Slice(list, func(i, j int) bool { return list[i].ID < list[j].ID }) + return list, nil } // --- USER OPERATIONS --- @@ -247,12 +181,79 @@ func (s *Store) GetUserByEmail(email string) (models.User, bool) { return models.User{}, false } -func (s *Store) CreateUser(user models.User) models.User { +func (s *Store) LookupUserByID(id string) (models.User, bool, error) { + user, ok := s.GetUserByID(id) + return user, ok, nil +} + +func (s *Store) LookupUserByEmail(email string) (models.User, bool, error) { + user, ok := s.GetUserByEmail(email) + return user, ok, nil +} + +func (s *Store) CreateUser(user models.User) (models.User, error) { s.mu.Lock() defer s.mu.Unlock() + for id, existing := range s.users { + if id != user.ID && strings.EqualFold(existing.Email, user.Email) { + return models.User{}, fmt.Errorf("%w: a user with this email already exists", ErrConflict) + } + } s.users[user.ID] = user - s.saveUsersLocked() - return user + return user, nil +} + +func oauthIdentityKey(provider, subject string) string { return provider + "\x00" + subject } + +func (s *Store) LookupOAuthIdentity(provider, subject string) (models.User, bool, error) { + s.mu.RLock() + defer s.mu.RUnlock() + userID, ok := s.oauthIdentities[oauthIdentityKey(provider, subject)] + if !ok { + return models.User{}, false, nil + } + user, ok := s.users[userID] + if !ok { + return models.User{}, false, errors.New("OAuth identity references a missing user") + } + return user, true, nil +} + +func (s *Store) LinkOAuthIdentity(provider, subject, userID, _ string) error { + s.mu.Lock() + defer s.mu.Unlock() + if _, ok := s.users[userID]; !ok { + return errors.New("OAuth identity user does not exist") + } + key := oauthIdentityKey(provider, subject) + if linkedUserID, exists := s.oauthIdentities[key]; exists && linkedUserID != userID { + return fmt.Errorf("%w: OAuth identity is already linked", ErrConflict) + } + for identityKey, linkedUserID := range s.oauthIdentities { + if linkedUserID == userID && strings.HasPrefix(identityKey, provider+"\x00") && identityKey != key { + return fmt.Errorf("%w: account already has a different identity for this provider", ErrConflict) + } + } + s.oauthIdentities[key] = userID + return nil +} + +func (s *Store) CreateOAuthUserWithIdentity(user models.User, provider, subject, _ string) (models.User, error) { + s.mu.Lock() + defer s.mu.Unlock() + key := oauthIdentityKey(provider, subject) + if _, exists := s.oauthIdentities[key]; exists { + return models.User{}, fmt.Errorf("%w: OAuth identity already exists", ErrConflict) + } + for _, existing := range s.users { + if existing.ID == user.ID || strings.EqualFold(existing.Email, user.Email) { + return models.User{}, fmt.Errorf("%w: OAuth user already exists", ErrConflict) + } + } + user.AuthProvider = provider + s.users[user.ID] = user + s.oauthIdentities[key] = user.ID + return user, nil } // --- PROBLEM OPERATIONS --- @@ -264,9 +265,14 @@ func (s *Store) GetAllProblems() []models.Problem { for _, p := range s.problems { list = append(list, p) } + sort.Slice(list, func(i, j int) bool { return list[i].ID < list[j].ID }) return list } +func (s *Store) LookupAllProblems() ([]models.Problem, error) { + return s.GetAllProblems(), nil +} + func (s *Store) GetProblemByID(id string) (models.Problem, bool) { s.mu.RLock() defer s.mu.RUnlock() @@ -274,30 +280,100 @@ func (s *Store) GetProblemByID(id string) (models.Problem, bool) { return p, ok } -func (s *Store) SaveProblem(p models.Problem) models.Problem { +func (s *Store) LookupProblemByID(id string) (models.Problem, bool, error) { + problem, ok := s.GetProblemByID(id) + return problem, ok, nil +} + +func (s *Store) ProblemExists(id string) (bool, error) { + s.mu.RLock() + defer s.mu.RUnlock() + _, ok := s.problems[id] + return ok, nil +} + +func (s *Store) SaveProblem(p models.Problem) (models.Problem, error) { s.mu.Lock() defer s.mu.Unlock() - now := time.Now() + if strings.TrimSpace(p.ID) == "" { + return models.Problem{}, errors.New("problem ID is required") + } + normalizeProblemForPersistence(&p) + now := nextProblemUpdatedAt(time.Time{}) if existing, ok := s.problems[p.ID]; ok { p.CreatedAt = existing.CreatedAt + now = nextProblemUpdatedAt(existing.UpdatedAt) } else { p.CreatedAt = now } p.UpdatedAt = now s.problems[p.ID] = p - s.saveProblemsLocked() - return p + return p, nil +} + +// SaveProblemIfUnchanged is the in-memory optimistic-concurrency equivalent +// of PostgreSQL's guarded UPDATE. The store-wide mutex also serializes it with +// seed resets and deletes, so tests observe the same catalog mutation contract +// as production. +func (s *Store) SaveProblemIfUnchanged(p models.Problem, expectedUpdatedAt time.Time) (models.Problem, error) { + s.mu.Lock() + defer s.mu.Unlock() + if strings.TrimSpace(p.ID) == "" { + return models.Problem{}, errors.New("problem ID is required") + } + existing, ok := s.problems[p.ID] + if !ok || expectedUpdatedAt.IsZero() || !existing.UpdatedAt.Equal(expectedUpdatedAt) { + return models.Problem{}, fmt.Errorf("%w: problem %s changed after it was loaded", ErrConflict, p.ID) + } + + normalizeProblemForPersistence(&p) + p.CreatedAt = existing.CreatedAt + p.UpdatedAt = nextProblemUpdatedAt(existing.UpdatedAt) + s.problems[p.ID] = p + return p, nil } -func (s *Store) DeleteProblem(id string) bool { +func nextProblemUpdatedAt(previous time.Time) time.Time { + now := time.Now().UTC().Truncate(time.Microsecond) + previous = previous.UTC().Truncate(time.Microsecond) + if !previous.IsZero() && !now.After(previous) { + return previous.Add(time.Microsecond) + } + return now +} + +func normalizeProblemForPersistence(problem *models.Problem) { + normalizeProblemDefaults(problem) + allCases := normalizedProblemTestCases(*problem) + problem.TestCases = make([]models.TestCase, 0, len(allCases)) + problem.HiddenTestCases = make([]models.TestCase, 0) + for _, testCase := range allCases { + if testCase.Visibility == models.TestHidden { + problem.HiddenTestCases = append(problem.HiddenTestCases, testCase) + } else { + problem.TestCases = append(problem.TestCases, testCase) + } + } +} + +func (s *Store) DeleteProblem(id string) (bool, error) { s.mu.Lock() defer s.mu.Unlock() + for _, submission := range s.submissions { + if submission.ProblemID == id { + return false, fmt.Errorf("%w: submission %s references %s", ErrProblemReferenced, submission.ID, id) + } + } + for _, workspace := range s.workspaces { + if workspace.ProblemID == id { + return false, fmt.Errorf("%w: workspace for %s references %s", ErrProblemWorkspaceReferenced, workspace.UserID, id) + } + } if _, ok := s.problems[id]; ok { delete(s.problems, id) - s.saveProblemsLocked() - return true + return true, nil } - return false + return false, nil } // --- SUBMISSION OPERATIONS --- @@ -309,27 +385,141 @@ func (s *Store) GetAllSubmissions() []models.Submission { for _, sub := range s.submissions { list = append(list, sub) } + sortSubmissions(list) return list } func (s *Store) GetSubmissionsByUserID(userID string) []models.Submission { s.mu.RLock() defer s.mu.RUnlock() - var list []models.Submission + list := make([]models.Submission, 0) for _, sub := range s.submissions { if sub.UserID == userID { list = append(list, sub) } } + sortSubmissions(list) return list } -func (s *Store) SaveSubmission(sub models.Submission) models.Submission { +func (s *Store) LookupSubmissionPage(query SubmissionPageQuery) (SubmissionPageResult, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + limit := query.Limit + if limit <= 0 { + limit = DefaultSubmissionPageSize + } + if limit > MaxSubmissionPageSize { + limit = MaxSubmissionPageSize + } + items := make([]models.SubmissionSummary, 0, len(s.submissions)) + for _, submission := range s.submissions { + if query.OwnerUserID != "" && submission.UserID != query.OwnerUserID { + continue + } + if query.ProblemID != "" && submission.ProblemID != query.ProblemID { + continue + } + if !query.BeforeCreatedAt.IsZero() && !submissionComesAfterCursor(submission.CreatedAt, submission.ID, query.BeforeCreatedAt, query.BeforeID) { + continue + } + items = append(items, submissionSummary(submission)) + } + sort.Slice(items, func(i, j int) bool { + if items[i].CreatedAt.Equal(items[j].CreatedAt) { + return items[i].ID > items[j].ID + } + return items[i].CreatedAt.After(items[j].CreatedAt) + }) + hasMore := len(items) > limit + if hasMore { + items = items[:limit] + } + return SubmissionPageResult{Items: items, HasMore: hasMore}, nil +} + +func submissionComesAfterCursor(createdAt time.Time, id string, cursorTime time.Time, cursorID string) bool { + return createdAt.Before(cursorTime) || (createdAt.Equal(cursorTime) && id < cursorID) +} + +func submissionSummary(submission models.Submission) models.SubmissionSummary { + return models.SubmissionSummary{ + ID: submission.ID, + UserID: submission.UserID, + UserEmail: submission.UserEmail, + ProblemID: submission.ProblemID, + ProblemTitle: submission.ProblemTitle, + EvaluationRevision: submission.EvaluationRevision, + Language: submission.Language, + Status: submission.Status, + PassedTests: submission.PassedTests, + TotalTests: submission.TotalTests, + Score: submission.Score, + DurationMs: submission.DurationMs, + CreatedAt: submission.CreatedAt, + } +} + +func (s *Store) LookupSubmissionByID(id, ownerUserID string) (models.Submission, bool, error) { + s.mu.RLock() + defer s.mu.RUnlock() + submission, found := s.submissions[id] + if !found || (ownerUserID != "" && submission.UserID != ownerUserID) { + return models.Submission{}, false, nil + } + return submission, true, nil +} + +func (s *Store) AggregateSubmissionProgress(userID string) (models.UserProgress, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + progress := models.UserProgress{SolvedProblemIDs: make([]string, 0)} + solved := make(map[string]struct{}) + for _, submission := range s.submissions { + if submission.UserID != userID { + continue + } + progress.SubmissionCount++ + if submission.Status != models.SubmissionStatusAccepted { + continue + } + progress.AcceptedCount++ + solved[submission.ProblemID] = struct{}{} + } + for problemID := range solved { + progress.SolvedProblemIDs = append(progress.SolvedProblemIDs, problemID) + } + sort.Strings(progress.SolvedProblemIDs) + progress.SolvedCount = len(progress.SolvedProblemIDs) + return progress, nil +} + +func sortSubmissions(items []models.Submission) { + sort.Slice(items, func(i, j int) bool { + if items[i].CreatedAt.Equal(items[j].CreatedAt) { + return items[i].ID < items[j].ID + } + return items[i].CreatedAt.After(items[j].CreatedAt) + }) +} + +func (s *Store) SaveSubmission(sub models.Submission) (models.Submission, error) { s.mu.Lock() defer s.mu.Unlock() + if _, exists := s.submissions[sub.ID]; exists { + return models.Submission{}, errors.New("a submission with this id already exists") + } + if _, exists := s.users[sub.UserID]; !exists { + return models.Submission{}, errors.New("submission user does not exist") + } + // Submissions retain the immutable problem/evaluation snapshot captured at + // queue admission. The catalog row may have been safely removed before a + // queued execution completes, so problem_id is historical metadata rather + // than a live foreign-key requirement. s.submissions[sub.ID] = sub - s.saveSubmissionsLocked() - return sub + return sub, nil } // --- WORKSPACE OPERATIONS --- @@ -345,37 +535,56 @@ func (s *Store) GetWorkspace(userID, problemID string, language models.Language) return workspace, ok } -func (s *Store) SaveWorkspace(workspace models.Workspace) models.Workspace { +func (s *Store) LookupWorkspace(userID, problemID string, language models.Language) (models.Workspace, bool, error) { + workspace, ok := s.GetWorkspace(userID, problemID, language) + return workspace, ok, nil +} + +func (s *Store) SaveWorkspace(workspace models.Workspace) (models.Workspace, error) { s.mu.Lock() defer s.mu.Unlock() - workspace.UpdatedAt = time.Now() - s.workspaces[workspaceKey(workspace.UserID, workspace.ProblemID, workspace.Language)] = workspace - s.saveWorkspacesLocked() - return workspace + if _, exists := s.users[workspace.UserID]; !exists { + return models.Workspace{}, errors.New("workspace user does not exist") + } + if _, exists := s.problems[workspace.ProblemID]; !exists { + return models.Workspace{}, errors.New("workspace problem does not exist") + } + key := workspaceKey(workspace.UserID, workspace.ProblemID, workspace.Language) + existing, exists := s.workspaces[key] + if (!exists && workspace.Revision != 0) || (exists && workspace.Revision != existing.Revision) { + return models.Workspace{}, fmt.Errorf("%w: workspace changed after it was loaded", ErrConflict) + } + workspace.Revision++ + workspace.UpdatedAt = time.Now().UTC() + s.workspaces[key] = workspace + return workspace, nil } -func (s *Store) DeleteWorkspace(userID, problemID string, language models.Language) bool { +func (s *Store) DeleteWorkspace(userID, problemID string, language models.Language) (bool, error) { s.mu.Lock() defer s.mu.Unlock() key := workspaceKey(userID, problemID, language) if _, ok := s.workspaces[key]; !ok { - return false + return false, nil } delete(s.workspaces, key) - s.saveWorkspacesLocked() - return true + return true, nil } // --- REFRESH TOKEN OPERATIONS --- -func (s *Store) SaveRefreshToken(token models.RefreshToken) { +func (s *Store) SaveRefreshToken(token models.RefreshToken) error { s.mu.Lock() defer s.mu.Unlock() + s.cleanupExpiredAuthArtifactsLocked(time.Now()) if token.FamilyID == "" { token.FamilyID = token.ID } + if err := s.validateRefreshTokenWriteLocked(token); err != nil { + return err + } s.refreshTokens[token.ID] = token - s.saveRefreshTokensLocked() + return nil } func (s *Store) GetRefreshTokenByHash(tokenHash string) (models.RefreshToken, bool) { @@ -392,6 +601,92 @@ func (s *Store) GetRefreshTokenByHash(tokenHash string) (models.RefreshToken, bo return models.RefreshToken{}, false } +func (s *Store) RotateRefreshToken(tokenHash string, replacement models.RefreshToken, now time.Time) (models.RefreshToken, RefreshTokenRotationOutcome, error) { + s.mu.Lock() + defer s.mu.Unlock() + + var old models.RefreshToken + found := false + for _, token := range s.refreshTokens { + if token.TokenHash == tokenHash { + old = token + found = true + break + } + } + if !found { + return models.RefreshToken{}, RefreshTokenNotFound, nil + } + if old.FamilyID == "" { + old.FamilyID = old.ID + } + + if old.RevokedAt != nil { + s.revokeRefreshTokenFamilyLocked(old.FamilyID, now) + return old, RefreshTokenReused, nil + } + if !old.ExpiresAt.After(now) { + s.revokeRefreshTokenFamilyLocked(old.FamilyID, now) + old.RevokedAt = timePointer(now) + return old, RefreshTokenExpired, nil + } + + replacement.UserID = old.UserID + replacement.FamilyID = old.FamilyID + replacement.RevokedAt = nil + replacement.ExpiresAt = old.ExpiresAt + + generations := 0 + for _, token := range s.refreshTokens { + familyID := token.FamilyID + if familyID == "" { + familyID = token.ID + } + if familyID == old.FamilyID { + generations++ + } + } + if generations >= refreshTokenFamilyMaxGenerations { + s.revokeRefreshTokenFamilyLocked(old.FamilyID, now) + old.RevokedAt = timePointer(now) + return old, RefreshTokenGenerationLimit, nil + } + if err := s.validateRefreshTokenWriteLocked(replacement); err != nil { + return old, "", err + } + + // Validation (including the injectable failure hook) happens before either + // map is changed, mirroring the all-or-nothing PostgreSQL transaction. + old.RevokedAt = timePointer(now) + s.refreshTokens[old.ID] = old + s.refreshTokens[replacement.ID] = replacement + s.cleanupExpiredAuthArtifactsLocked(now) + return old, RefreshTokenRotated, nil +} + +func (s *Store) validateRefreshTokenWriteLocked(token models.RefreshToken) error { + if token.ID == "" || token.UserID == "" || token.TokenHash == "" { + return errors.New("refresh token id, user, and hash are required") + } + if _, exists := s.users[token.UserID]; !exists { + return errors.New("refresh token user does not exist") + } + if _, exists := s.refreshTokens[token.ID]; exists { + return errors.New("a refresh token with this id already exists") + } + for _, existing := range s.refreshTokens { + if existing.TokenHash == token.TokenHash { + return errors.New("a refresh token with this hash already exists") + } + } + if s.refreshTokenWriteHook != nil { + if err := s.refreshTokenWriteHook(token); err != nil { + return err + } + } + return nil +} + func (s *Store) RevokeRefreshToken(id string) bool { s.mu.Lock() defer s.mu.Unlock() @@ -399,17 +694,34 @@ func (s *Store) RevokeRefreshToken(id string) bool { if !ok || token.RevokedAt != nil { return false } - now := time.Now() - token.RevokedAt = &now + token.RevokedAt = timePointer(time.Now()) s.refreshTokens[id] = token - s.saveRefreshTokensLocked() return true } func (s *Store) RevokeRefreshTokenFamily(familyID string) bool { s.mu.Lock() defer s.mu.Unlock() - now := time.Now() + return s.revokeRefreshTokenFamilyLocked(familyID, time.Now()) +} + +func (s *Store) RevokeRefreshTokenFamilyByHash(tokenHash string, now time.Time) (bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + for _, token := range s.refreshTokens { + if token.TokenHash != tokenHash { + continue + } + familyID := token.FamilyID + if familyID == "" { + familyID = token.ID + } + return s.revokeRefreshTokenFamilyLocked(familyID, now), nil + } + return false, nil +} + +func (s *Store) revokeRefreshTokenFamilyLocked(familyID string, now time.Time) bool { revoked := false for id, token := range s.refreshTokens { fid := token.FamilyID @@ -417,32 +729,62 @@ func (s *Store) RevokeRefreshTokenFamily(familyID string) bool { fid = token.ID } if fid == familyID && token.RevokedAt == nil { - token.RevokedAt = &now + token.RevokedAt = timePointer(now) s.refreshTokens[id] = token revoked = true } } - if revoked { - s.saveRefreshTokensLocked() - } return revoked } +func timePointer(value time.Time) *time.Time { return &value } + func (s *Store) SaveAuthCode(codeHash, userID string, expiresAt time.Time) error { s.mu.Lock() defer s.mu.Unlock() + s.cleanupExpiredAuthArtifactsLocked(time.Now()) s.authCodes[codeHash] = authCodeRecord{UserID: userID, ExpiresAt: expiresAt} return nil } +func (s *Store) cleanupExpiredAuthArtifactsLocked(now time.Time) { + cutoff := now.Add(-authArtifactExpiryGrace) + deleted := 0 + for id, token := range s.refreshTokens { + if deleted >= authArtifactCleanupBatch { + break + } + if token.ExpiresAt.Before(cutoff) { + delete(s.refreshTokens, id) + deleted++ + } + } + + deleted = 0 + for hash, code := range s.authCodes { + if deleted >= authArtifactCleanupBatch { + break + } + if code.ExpiresAt.Before(cutoff) { + delete(s.authCodes, hash) + deleted++ + } + } +} + func (s *Store) ConsumeAuthCode(codeHash string) (string, bool) { + userID, ok, _ := s.ConsumeAuthCodeWithError(codeHash) + return userID, ok +} + +func (s *Store) ConsumeAuthCodeWithError(codeHash string) (string, bool, error) { s.mu.Lock() defer s.mu.Unlock() record, ok := s.authCodes[codeHash] if !ok || record.Used || time.Now().After(record.ExpiresAt) { - return "", false + return "", false, nil } record.Used = true s.authCodes[codeHash] = record - return record.UserID, true + return record.UserID, true, nil } diff --git a/interviewhub-backend-go/internal/store/store_test.go b/interviewhub-backend-go/internal/store/store_test.go new file mode 100644 index 00000000..d81f85e9 --- /dev/null +++ b/interviewhub-backend-go/internal/store/store_test.go @@ -0,0 +1,717 @@ +package store + +import ( + "errors" + "fmt" + "sync" + "testing" + "time" + + "github.com/interviewhub/backend/internal/models" +) + +func TestNewMemoryStoreReturnsIsolatedDeterministicState(t *testing.T) { + first := NewMemoryStore() + second := NewMemoryStore() + + created, err := first.CreateUser(models.User{ + ID: "usr_isolation", + Name: "Only First", + Email: "only-first@example.com", + Role: models.RoleCandidate, + CreatedAt: time.Now(), + }) + if err != nil { + t.Fatalf("create first-store user: %v", err) + } + if created.ID == "" { + t.Fatal("failed to create first-store user") + } + if _, exists := second.GetUserByID(created.ID); exists { + t.Fatal("a fresh memory store observed another store's user") + } + + if _, err := first.SaveSubmission(models.Submission{ + ID: "sub_isolation", + UserID: "usr_cand_01", + ProblemID: "parking-lot", + CreatedAt: memorySeedTime, + }); err != nil { + t.Fatalf("save first-store submission: %v", err) + } + if submissions := second.GetAllSubmissions(); len(submissions) != 0 { + t.Fatalf("fresh memory store inherited submissions: %+v", submissions) + } + + firstProblems := first.GetAllProblems() + secondProblems := second.GetAllProblems() + if len(firstProblems) == 0 || len(firstProblems) != len(secondProblems) { + t.Fatalf("seed catalogs differ: first=%d second=%d", len(firstProblems), len(secondProblems)) + } + for index := range firstProblems { + if firstProblems[index].ID != secondProblems[index].ID { + t.Fatalf("problem order differs at %d: %q != %q", index, firstProblems[index].ID, secondProblems[index].ID) + } + if !firstProblems[index].CreatedAt.Equal(memorySeedTime) || !secondProblems[index].CreatedAt.Equal(memorySeedTime) { + t.Fatalf("problem seed timestamps are not deterministic for %q", firstProblems[index].ID) + } + } +} + +func TestMemoryStoreRefreshRotationRollsBackFailedReplacement(t *testing.T) { + repository := NewMemoryStore() + now := time.Date(2026, time.August, 7, 12, 0, 0, 0, time.UTC) + old := models.RefreshToken{ + ID: "rt_old", + UserID: "usr_cand_01", + FamilyID: "fam_test", + TokenHash: "old-hash", + CreatedAt: now.Add(-time.Minute), + ExpiresAt: now.Add(time.Hour), + } + if err := repository.SaveRefreshToken(old); err != nil { + t.Fatalf("save old refresh token: %v", err) + } + + injected := errors.New("injected replacement write failure") + repository.refreshTokenWriteHook = func(token models.RefreshToken) error { + if token.ID == "rt_replacement" { + return injected + } + return nil + } + replacement := models.RefreshToken{ + ID: "rt_replacement", + TokenHash: "replacement-hash", + CreatedAt: now, + ExpiresAt: now.Add(time.Hour), + } + _, outcome, err := repository.RotateRefreshToken(old.TokenHash, replacement, now) + if !errors.Is(err, injected) { + t.Fatalf("rotation error=%v, want injected failure", err) + } + if outcome != "" { + t.Fatalf("failed rotation outcome=%q, want empty/indeterminate", outcome) + } + storedOld, ok := repository.GetRefreshTokenByHash(old.TokenHash) + if !ok || storedOld.RevokedAt != nil { + t.Fatalf("failed rotation consumed old token: %+v, exists=%v", storedOld, ok) + } + if _, ok := repository.GetRefreshTokenByHash(replacement.TokenHash); ok { + t.Fatal("failed rotation persisted its replacement") + } + + repository.refreshTokenWriteHook = nil + _, outcome, err = repository.RotateRefreshToken(old.TokenHash, replacement, now) + if err != nil || outcome != RefreshTokenRotated { + t.Fatalf("retry rotation=(%q, %v), want rotated", outcome, err) + } +} + +func TestMemoryStoreConcurrentRefreshRotationDetectsReplay(t *testing.T) { + repository := NewMemoryStore() + now := time.Date(2026, time.August, 7, 12, 0, 0, 0, time.UTC) + old := models.RefreshToken{ + ID: "rt_concurrent_old", + UserID: "usr_cand_01", + FamilyID: "fam_concurrent", + TokenHash: "concurrent-old-hash", + CreatedAt: now.Add(-time.Minute), + ExpiresAt: now.Add(time.Hour), + } + if err := repository.SaveRefreshToken(old); err != nil { + t.Fatalf("save old refresh token: %v", err) + } + + replacements := []models.RefreshToken{ + {ID: "rt_concurrent_a", TokenHash: "concurrent-a-hash", CreatedAt: now, ExpiresAt: now.Add(time.Hour)}, + {ID: "rt_concurrent_b", TokenHash: "concurrent-b-hash", CreatedAt: now, ExpiresAt: now.Add(time.Hour)}, + } + outcomes := make(chan RefreshTokenRotationOutcome, len(replacements)) + errorsSeen := make(chan error, len(replacements)) + var wait sync.WaitGroup + for _, replacement := range replacements { + replacement := replacement + wait.Add(1) + go func() { + defer wait.Done() + _, outcome, err := repository.RotateRefreshToken(old.TokenHash, replacement, now) + outcomes <- outcome + errorsSeen <- err + }() + } + wait.Wait() + close(outcomes) + close(errorsSeen) + + for err := range errorsSeen { + if err != nil { + t.Fatalf("concurrent rotation returned storage error: %v", err) + } + } + counts := map[RefreshTokenRotationOutcome]int{} + for outcome := range outcomes { + counts[outcome]++ + } + if counts[RefreshTokenRotated] != 1 || counts[RefreshTokenReused] != 1 { + t.Fatalf("rotation outcomes=%v, want one rotated and one reused", counts) + } + + persistedReplacements := 0 + for _, replacement := range replacements { + if stored, ok := repository.GetRefreshTokenByHash(replacement.TokenHash); ok { + persistedReplacements++ + if stored.RevokedAt == nil { + t.Fatalf("replay left replacement active: %+v", stored) + } + } + } + if persistedReplacements != 1 { + t.Fatalf("persisted replacements=%d, want exactly one", persistedReplacements) + } +} + +func TestMemoryStoreRefreshFamilyHasAbsoluteLifetime(t *testing.T) { + repository := NewMemoryStore() + startedAt := time.Date(2026, time.August, 7, 12, 0, 0, 0, time.UTC) + absoluteExpiry := startedAt.Add(24 * time.Hour) + activeHash := "absolute-family-0" + if err := repository.SaveRefreshToken(models.RefreshToken{ + ID: "rt_absolute_0", + UserID: "usr_cand_01", + FamilyID: "fam_absolute", + TokenHash: activeHash, + CreatedAt: startedAt, + ExpiresAt: absoluteExpiry, + }); err != nil { + t.Fatal(err) + } + + rotationTime := startedAt.Add(time.Minute) + nextHash := "absolute-family-1" + _, outcome, err := repository.RotateRefreshToken(activeHash, models.RefreshToken{ + ID: "rt_absolute_1", + TokenHash: nextHash, + CreatedAt: rotationTime, + // A caller cannot extend the family by proposing a later deadline. + ExpiresAt: absoluteExpiry.Add(365 * 24 * time.Hour), + }, rotationTime) + if err != nil || outcome != RefreshTokenRotated { + t.Fatalf("rotation = (%q, %v), want rotated", outcome, err) + } + persisted, found := repository.GetRefreshTokenByHash(nextHash) + if !found || !persisted.ExpiresAt.Equal(absoluteExpiry) { + t.Fatalf("replacement expiry = %v found=%v, want %v", persisted.ExpiresAt, found, absoluteExpiry) + } +} + +func TestMemoryStoreRefreshFamilyGenerationCapIsBounded(t *testing.T) { + repository := NewMemoryStore() + startedAt := time.Date(2026, time.August, 7, 12, 0, 0, 0, time.UTC) + absoluteExpiry := startedAt.Add(30 * 24 * time.Hour) + activeHash := "bounded-family-active" + revokedAt := startedAt.Add(time.Minute) + for generation := 0; generation < refreshTokenFamilyMaxGenerations; generation++ { + token := models.RefreshToken{ + ID: fmt.Sprintf("rt_bounded_%d", generation), + UserID: "usr_cand_01", + FamilyID: "fam_bounded", + TokenHash: fmt.Sprintf("bounded-family-%d", generation), + CreatedAt: startedAt.Add(time.Duration(generation) * time.Second), + ExpiresAt: absoluteExpiry, + RevokedAt: &revokedAt, + } + if generation == refreshTokenFamilyMaxGenerations-1 { + token.TokenHash = activeHash + token.RevokedAt = nil + } + repository.refreshTokens[token.ID] = token + } + + limitTime := startedAt.Add(2 * time.Hour) + _, outcome, err := repository.RotateRefreshToken(activeHash, models.RefreshToken{ + ID: "rt_over_limit", TokenHash: "bounded-family-over-limit", CreatedAt: limitTime, ExpiresAt: limitTime.Add(24 * time.Hour), + }, limitTime) + if err != nil || outcome != RefreshTokenGenerationLimit { + t.Fatalf("over-limit rotation = (%q, %v), want generation_limit", outcome, err) + } + + familyRows, activeRows := 0, 0 + for _, token := range repository.refreshTokens { + if token.FamilyID != "fam_bounded" { + continue + } + familyRows++ + if token.RevokedAt == nil { + activeRows++ + } + } + if familyRows != refreshTokenFamilyMaxGenerations || activeRows != 0 { + t.Fatalf("family rows=%d active=%d, want bounded rows=%d active=0", familyRows, activeRows, refreshTokenFamilyMaxGenerations) + } + if _, found := repository.GetRefreshTokenByHash("bounded-family-over-limit"); found { + t.Fatal("generation-limit rotation persisted a replacement") + } +} + +func TestMemoryStoreConcurrentLogoutAndRotationLeaveNoActiveFamily(t *testing.T) { + repository := NewMemoryStore() + now := time.Date(2026, time.August, 7, 12, 0, 0, 0, time.UTC) + old := models.RefreshToken{ + ID: "rt_logout_race_old", UserID: "usr_cand_01", FamilyID: "fam_logout_race", + TokenHash: "logout-race-old", CreatedAt: now.Add(-time.Minute), ExpiresAt: now.Add(time.Hour), + } + if err := repository.SaveRefreshToken(old); err != nil { + t.Fatal(err) + } + + start := make(chan struct{}) + rotationOutcome := make(chan RefreshTokenRotationOutcome, 1) + rotationError := make(chan error, 1) + logoutResult := make(chan bool, 1) + logoutError := make(chan error, 1) + var wait sync.WaitGroup + wait.Add(2) + go func() { + defer wait.Done() + <-start + _, outcome, err := repository.RotateRefreshToken(old.TokenHash, models.RefreshToken{ + ID: "rt_logout_race_next", TokenHash: "logout-race-next", CreatedAt: now, ExpiresAt: now.Add(2 * time.Hour), + }, now) + rotationOutcome <- outcome + rotationError <- err + }() + go func() { + defer wait.Done() + <-start + revoked, err := repository.RevokeRefreshTokenFamilyByHash(old.TokenHash, now) + logoutResult <- revoked + logoutError <- err + }() + close(start) + wait.Wait() + + if err := <-rotationError; err != nil { + t.Fatalf("rotation error: %v", err) + } + outcome := <-rotationOutcome + if outcome != RefreshTokenRotated && outcome != RefreshTokenReused { + t.Fatalf("rotation outcome=%q, want rotated or reused", outcome) + } + if err := <-logoutError; err != nil { + t.Fatalf("logout error: %v", err) + } + if revoked := <-logoutResult; !revoked { + t.Fatal("concurrent logout did not revoke the family") + } + for _, token := range repository.refreshTokens { + if token.FamilyID == old.FamilyID && token.RevokedAt == nil { + t.Fatalf("logout/rotation race left active token: %+v", token) + } + } +} + +func TestMemoryStoreRefreshRotationHasExplicitInvalidOutcomes(t *testing.T) { + repository := NewMemoryStore() + now := time.Date(2026, time.August, 7, 12, 0, 0, 0, time.UTC) + replacement := models.RefreshToken{ID: "rt_next", TokenHash: "next-hash", CreatedAt: now, ExpiresAt: now.Add(time.Hour)} + + if _, outcome, err := repository.RotateRefreshToken("missing-hash", replacement, now); err != nil || outcome != RefreshTokenNotFound { + t.Fatalf("missing rotation=(%q,%v), want not_found", outcome, err) + } + expired := models.RefreshToken{ + ID: "rt_expired", + UserID: "usr_cand_01", + FamilyID: "fam_expired", + TokenHash: "expired-hash", + CreatedAt: now.Add(-2 * time.Hour), + ExpiresAt: now, + } + if err := repository.SaveRefreshToken(expired); err != nil { + t.Fatalf("save expired refresh token: %v", err) + } + if _, outcome, err := repository.RotateRefreshToken(expired.TokenHash, replacement, now); err != nil || outcome != RefreshTokenExpired { + t.Fatalf("expired rotation=(%q,%v), want expired", outcome, err) + } + stored, ok := repository.GetRefreshTokenByHash(expired.TokenHash) + if !ok || stored.RevokedAt == nil { + t.Fatalf("expired token was not explicitly revoked: %+v, exists=%v", stored, ok) + } +} + +func TestMemoryStoreRevokesRefreshFamilyAtomicallyByTokenHash(t *testing.T) { + repository := NewMemoryStore() + now := time.Now() + tokens := []models.RefreshToken{ + {ID: "rt_logout_1", UserID: "usr_cand_01", FamilyID: "fam_logout", TokenHash: "logout-hash-1", CreatedAt: now, ExpiresAt: now.Add(time.Hour)}, + {ID: "rt_logout_2", UserID: "usr_cand_01", FamilyID: "fam_logout", TokenHash: "logout-hash-2", CreatedAt: now, ExpiresAt: now.Add(time.Hour)}, + } + for _, token := range tokens { + if err := repository.SaveRefreshToken(token); err != nil { + t.Fatalf("SaveRefreshToken(%s): %v", token.ID, err) + } + } + + revoked, err := repository.RevokeRefreshTokenFamilyByHash(tokens[0].TokenHash, now.Add(time.Minute)) + if err != nil || !revoked { + t.Fatalf("RevokeRefreshTokenFamilyByHash=(%v,%v), want true,nil", revoked, err) + } + for _, token := range tokens { + stored, ok := repository.GetRefreshTokenByHash(token.TokenHash) + if !ok || stored.RevokedAt == nil { + t.Fatalf("family token %s was not revoked: %+v, exists=%v", token.ID, stored, ok) + } + } + if revoked, err := repository.RevokeRefreshTokenFamilyByHash("missing-hash", now); err != nil || revoked { + t.Fatalf("missing hash revoke=(%v,%v), want false,nil", revoked, err) + } +} + +func TestMemoryStoreOpportunisticallyPrunesExpiredAuthArtifactsInBatches(t *testing.T) { + repository := NewMemoryStore() + now := time.Now() + staleExpiry := now.Add(-authArtifactExpiryGrace - time.Hour) + for index := 0; index < authArtifactCleanupBatch+10; index++ { + id := fmt.Sprintf("rt_stale_%03d", index) + repository.refreshTokens[id] = models.RefreshToken{ + ID: id, + UserID: "usr_cand_01", + FamilyID: id, + TokenHash: "hash_" + id, + CreatedAt: staleExpiry.Add(-time.Hour), + ExpiresAt: staleExpiry, + } + repository.authCodes[fmt.Sprintf("code_stale_%03d", index)] = authCodeRecord{ + UserID: "usr_cand_01", + ExpiresAt: staleExpiry, + Used: true, + } + } + unexpired := models.RefreshToken{ + ID: "rt_unexpired", + UserID: "usr_cand_01", + FamilyID: "fam_unexpired", + TokenHash: "hash_unexpired", + CreatedAt: now, + ExpiresAt: now.Add(time.Hour), + } + repository.refreshTokens[unexpired.ID] = unexpired + repository.authCodes["code_unexpired"] = authCodeRecord{UserID: "usr_cand_01", ExpiresAt: now.Add(time.Hour)} + + if err := repository.SaveRefreshToken(models.RefreshToken{ + ID: "rt_fresh", + UserID: "usr_cand_01", + FamilyID: "fam_fresh", + TokenHash: "hash_fresh", + CreatedAt: now, + ExpiresAt: now.Add(time.Hour), + }); err != nil { + t.Fatalf("SaveRefreshToken: %v", err) + } + if got := countStaleRefreshTokens(repository, now); got != 10 { + t.Fatalf("stale refresh tokens after one batch=%d, want 10", got) + } + if got := countStaleAuthCodes(repository, now); got != 10 { + t.Fatalf("stale auth codes after one batch=%d, want 10", got) + } + + if err := repository.SaveAuthCode("code_fresh", "usr_cand_01", now.Add(time.Hour)); err != nil { + t.Fatalf("SaveAuthCode: %v", err) + } + if got := countStaleRefreshTokens(repository, now); got != 0 { + t.Fatalf("stale refresh tokens after second batch=%d, want 0", got) + } + if got := countStaleAuthCodes(repository, now); got != 0 { + t.Fatalf("stale auth codes after second batch=%d, want 0", got) + } + if _, ok := repository.refreshTokens[unexpired.ID]; !ok { + t.Fatal("cleanup removed an unexpired refresh token") + } + if _, ok := repository.authCodes["code_unexpired"]; !ok { + t.Fatal("cleanup removed an unexpired auth code") + } +} + +func countStaleRefreshTokens(repository *Store, now time.Time) int { + cutoff := now.Add(-authArtifactExpiryGrace) + count := 0 + for _, token := range repository.refreshTokens { + if token.ExpiresAt.Before(cutoff) { + count++ + } + } + return count +} + +func countStaleAuthCodes(repository *Store, now time.Time) int { + cutoff := now.Add(-authArtifactExpiryGrace) + count := 0 + for _, code := range repository.authCodes { + if code.ExpiresAt.Before(cutoff) { + count++ + } + } + return count +} + +func TestMemoryStoreSeedsUsableLocalCredentials(t *testing.T) { + repository := NewMemoryStore() + for _, email := range []string{"admin@interviewhub.io", "candidate@google.com"} { + user, ok := repository.GetUserByEmail(email) + if !ok { + t.Fatalf("seed user %q missing", email) + } + if user.PasswordHash == "" { + t.Fatalf("seed user %q has no password hash", email) + } + if user.AuthProvider != "local" { + t.Fatalf("seed user %q provider=%q, want local", email, user.AuthProvider) + } + } +} + +func TestMemoryProblemCompareAndSwapRejectsStaleAndResetRevisions(t *testing.T) { + repository := NewMemoryStore() + loaded, found := repository.GetProblemByID("parking-lot") + if !found { + t.Fatal("parking-lot seed is missing") + } + stale := loaded + loaded.Title = "CAS winner" + winner, err := repository.SaveProblemIfUnchanged(loaded, loaded.UpdatedAt) + if err != nil { + t.Fatalf("guarded save: %v", err) + } + if !winner.UpdatedAt.After(stale.UpdatedAt) { + t.Fatalf("winner revision %s did not advance past %s", winner.UpdatedAt, stale.UpdatedAt) + } + + stale.Title = "CAS loser" + if _, err := repository.SaveProblemIfUnchanged(stale, stale.UpdatedAt); !errors.Is(err, ErrConflict) { + t.Fatalf("stale guarded save error = %v, want ErrConflict", err) + } + stored, _ := repository.GetProblemByID("parking-lot") + if stored.Title != "CAS winner" { + t.Fatalf("stale guarded save overwrote title with %q", stored.Title) + } + + beforeReset := stored + if _, err := repository.ResetProblemsToDefaults(); err != nil { + t.Fatalf("reset catalog: %v", err) + } + beforeReset.Title = "Update based on pre-reset revision" + if _, err := repository.SaveProblemIfUnchanged(beforeReset, beforeReset.UpdatedAt); !errors.Is(err, ErrConflict) { + t.Fatalf("pre-reset guarded save error = %v, want ErrConflict", err) + } + afterReset, _ := repository.GetProblemByID("parking-lot") + if !afterReset.UpdatedAt.After(beforeReset.UpdatedAt) { + t.Fatalf("reset revision %s did not advance past %s", afterReset.UpdatedAt, beforeReset.UpdatedAt) + } +} + +func TestMemoryProblemCompareAndSwapAllowsExactlyOneConcurrentWriter(t *testing.T) { + repository := NewMemoryStore() + base, err := repository.SaveProblem(models.Problem{ID: "concurrent-cas", Title: "Before"}) + if err != nil { + t.Fatal(err) + } + + start := make(chan struct{}) + results := make(chan error, 2) + var wait sync.WaitGroup + for _, title := range []string{"Writer A", "Writer B"} { + problem := base + problem.Title = title + wait.Add(1) + go func() { + defer wait.Done() + <-start + _, err := repository.SaveProblemIfUnchanged(problem, base.UpdatedAt) + results <- err + }() + } + close(start) + wait.Wait() + close(results) + + succeeded, conflicted := 0, 0 + for err := range results { + switch { + case err == nil: + succeeded++ + case errors.Is(err, ErrConflict): + conflicted++ + default: + t.Fatalf("concurrent guarded save returned unexpected error: %v", err) + } + } + if succeeded != 1 || conflicted != 1 { + t.Fatalf("concurrent guarded saves: succeeded=%d conflicted=%d, want 1/1", succeeded, conflicted) + } + stored, found := repository.GetProblemByID(base.ID) + if !found || (stored.Title != "Writer A" && stored.Title != "Writer B") || !stored.UpdatedAt.After(base.UpdatedAt) { + t.Fatalf("unexpected concurrent CAS winner: found=%v problem=%+v", found, stored) + } +} + +func TestMemoryProblemResetPreservesArtifactsForRetainedSeedProblems(t *testing.T) { + repository := NewMemoryStore() + if _, err := repository.SaveSubmission(models.Submission{ + ID: "sub_catalog_reference", + UserID: "usr_cand_01", + ProblemID: "parking-lot", + CreatedAt: memorySeedTime, + }); err != nil { + t.Fatal(err) + } + workspace, err := repository.SaveWorkspace(models.Workspace{ + UserID: "usr_cand_01", + ProblemID: "parking-lot", + Language: models.LangGo, + Files: []models.FileNode{{ID: "draft", Name: "main.go", Content: "candidate draft"}}, + }) + if err != nil { + t.Fatal(err) + } + problem, found := repository.GetProblemByID("parking-lot") + if !found { + t.Fatal("parking-lot seed is missing") + } + problem.Title = "Locally changed title" + if _, err := repository.SaveProblem(problem); err != nil { + t.Fatal(err) + } + + if _, err := repository.ResetProblemsToDefaults(); err != nil { + t.Fatalf("reset retained seed: %v", err) + } + if deleted, err := repository.DeleteProblem("parking-lot"); deleted || !errors.Is(err, ErrProblemReferenced) { + t.Fatalf("delete=(%v,%v), want referenced conflict", deleted, err) + } + storedWorkspace, found := repository.GetWorkspace("usr_cand_01", "parking-lot", models.LangGo) + if !found || storedWorkspace.Revision != workspace.Revision || storedWorkspace.Files[0].Content != "candidate draft" { + t.Fatalf("reset changed retained workspace: found=%v workspace=%+v", found, storedWorkspace) + } + if submissions := repository.GetSubmissionsByUserID("usr_cand_01"); len(submissions) != 1 || submissions[0].ID != "sub_catalog_reference" { + t.Fatalf("reset changed retained submission history: %+v", submissions) + } + resetProblem, found := repository.GetProblemByID("parking-lot") + if !found || resetProblem.Title == "Locally changed title" { + t.Fatalf("seed problem was not refreshed in place: found=%v problem=%+v", found, resetProblem) + } +} + +func TestMemoryProblemResetDeletesOnlyUnreferencedObsoleteProblems(t *testing.T) { + repository := NewMemoryStore() + if _, err := repository.SaveProblem(models.Problem{ID: "obsolete-problem", Title: "Obsolete"}); err != nil { + t.Fatal(err) + } + if _, err := repository.ResetProblemsToDefaults(); err != nil { + t.Fatal(err) + } + if _, found := repository.GetProblemByID("obsolete-problem"); found { + t.Fatal("unreferenced obsolete problem survived reset") + } +} + +func TestMemoryProblemResetProtectsArtifactsForObsoleteProblems(t *testing.T) { + t.Run("submission history", func(t *testing.T) { + repository := NewMemoryStore() + if _, err := repository.SaveProblem(models.Problem{ID: "obsolete-submission", Title: "Obsolete"}); err != nil { + t.Fatal(err) + } + if _, err := repository.SaveSubmission(models.Submission{ + ID: "obsolete-submission-record", UserID: "usr_cand_01", ProblemID: "obsolete-submission", CreatedAt: memorySeedTime, + }); err != nil { + t.Fatal(err) + } + if _, err := repository.ResetProblemsToDefaults(); !errors.Is(err, ErrProblemReferenced) { + t.Fatalf("reset error=%v, want ErrProblemReferenced", err) + } + if _, found := repository.GetProblemByID("obsolete-submission"); !found { + t.Fatal("failed reset deleted submission-referenced problem") + } + }) + + t.Run("candidate workspace", func(t *testing.T) { + repository := NewMemoryStore() + if _, err := repository.SaveProblem(models.Problem{ID: "obsolete-workspace", Title: "Obsolete"}); err != nil { + t.Fatal(err) + } + if _, err := repository.SaveWorkspace(models.Workspace{ + UserID: "usr_cand_01", ProblemID: "obsolete-workspace", Language: models.LangGo, + }); err != nil { + t.Fatal(err) + } + if _, err := repository.ResetProblemsToDefaults(); !errors.Is(err, ErrProblemWorkspaceReferenced) { + t.Fatalf("reset error=%v, want ErrProblemWorkspaceReferenced", err) + } + if _, found := repository.GetProblemByID("obsolete-workspace"); !found { + t.Fatal("failed reset deleted workspace-referenced problem") + } + if _, found := repository.GetWorkspace("usr_cand_01", "obsolete-workspace", models.LangGo); !found { + t.Fatal("failed reset deleted obsolete problem workspace") + } + }) +} + +func TestMemoryProblemDeleteProtectsCandidateWorkspaces(t *testing.T) { + repository := NewMemoryStore() + if _, err := repository.SaveProblem(models.Problem{ID: "workspace-delete", Title: "Workspace delete"}); err != nil { + t.Fatal(err) + } + if _, err := repository.SaveWorkspace(models.Workspace{ + UserID: "usr_cand_01", ProblemID: "workspace-delete", Language: models.LangGo, + }); err != nil { + t.Fatal(err) + } + + deleted, err := repository.DeleteProblem("workspace-delete") + if deleted || !errors.Is(err, ErrProblemWorkspaceReferenced) { + t.Fatalf("DeleteProblem = (%v, %v), want workspace conflict", deleted, err) + } + if _, found := repository.GetProblemByID("workspace-delete"); !found { + t.Fatal("failed delete removed workspace-referenced problem") + } + if _, found := repository.GetWorkspace("usr_cand_01", "workspace-delete", models.LangGo); !found { + t.Fatal("failed delete removed candidate workspace") + } +} + +func TestMemoryProblemDeleteRemovesOnlyUnreferencedRows(t *testing.T) { + repository := NewMemoryStore() + if _, err := repository.SaveProblem(models.Problem{ID: "safe-delete", Title: "Safe delete"}); err != nil { + t.Fatal(err) + } + if deleted, err := repository.DeleteProblem("safe-delete"); err != nil || !deleted { + t.Fatalf("DeleteProblem existing = (%v, %v), want (true, nil)", deleted, err) + } + if deleted, err := repository.DeleteProblem("missing-delete"); err != nil || deleted { + t.Fatalf("DeleteProblem missing = (%v, %v), want (false, nil)", deleted, err) + } +} + +func TestProblemTestCaseNormalizationNeverDropsDuplicateOrMissingIDs(t *testing.T) { + testCases := normalizedProblemTestCases(models.Problem{ + ID: "identity-test", + TestCases: []models.TestCase{ + {ID: "tc-1", Visibility: models.TestPublic}, + {ID: "tc-1", Visibility: models.TestPublic}, + {Visibility: models.TestPublic}, + }, + HiddenTestCases: []models.TestCase{{ID: "tc-1", Visibility: models.TestHidden}}, + }) + if len(testCases) != 4 { + t.Fatalf("normalized cases=%d, want 4", len(testCases)) + } + seen := make(map[string]bool) + for _, testCase := range testCases { + if testCase.ID == "" || seen[testCase.ID] { + t.Fatalf("normalization returned invalid ID %q in %+v", testCase.ID, testCases) + } + seen[testCase.ID] = true + } + if testCases[3].Visibility != models.TestHidden { + t.Fatalf("hidden case visibility=%q", testCases[3].Visibility) + } +} diff --git a/interviewhub-backend-go/scripts/seed-db.sh b/interviewhub-backend-go/scripts/seed-db.sh index f3f36dbf..5f9cc9d0 100755 --- a/interviewhub-backend-go/scripts/seed-db.sh +++ b/interviewhub-backend-go/scripts/seed-db.sh @@ -1,7 +1,8 @@ #!/usr/bin/env bash # Portable Postgres migrate + seed. # Usage: -# ./scripts/seed-db.sh +# ./scripts/seed-db.sh # migrations + problem catalog +# ./scripts/seed-db.sh --users # also create local demo users # ./scripts/seed-db.sh --sync-problems # DATABASE_URL='postgres://...' ./scripts/seed-db.sh --migrate-only set -euo pipefail @@ -9,16 +10,16 @@ set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$ROOT" -if [ -f .env.local ]; then - set -a - # shellcheck disable=SC1091 - source .env.local - set +a -fi -if [ -f .env ]; then +if [ -z "${DATABASE_URL:-}" ]; then set -a - # shellcheck disable=SC1091 - source .env + if [ -f .env ]; then + # shellcheck disable=SC1091 + source .env + fi + if [ -f .env.local ]; then + # shellcheck disable=SC1091 + source .env.local + fi set +a fi @@ -28,5 +29,5 @@ if [ -z "${DATABASE_URL:-}" ]; then echo "DATABASE_URL not set; using local docker-compose default" fi -echo "Seeding against: ${DATABASE_URL%%@*}@***" +echo "Applying migrations/seeds using the configured DATABASE_URL" exec go run ./cmd/seed "$@" diff --git a/interviewhub-backend-go/test/api_test.go b/interviewhub-backend-go/test/api_test.go index 1cb4ea0c..8853a71d 100644 --- a/interviewhub-backend-go/test/api_test.go +++ b/interviewhub-backend-go/test/api_test.go @@ -8,58 +8,74 @@ import ( "testing" "time" - "github.com/go-chi/chi/v5" "github.com/interviewhub/backend/internal/config" "github.com/interviewhub/backend/internal/handlers" + "github.com/interviewhub/backend/internal/httpapi" "github.com/interviewhub/backend/internal/models" "github.com/interviewhub/backend/internal/services" "github.com/interviewhub/backend/internal/store" ) -func setupTestRouter() http.Handler { - cfg := &config.Config{ - Port: 5050, - JWTSecret: "test-secret", - DatabaseDir: "/tmp/interviewhub_test_db", - SandboxTempDir: "/tmp/interviewhub_test_sandboxes", - SandboxTimeoutSec: 5, - AccessTokenTTL: 15 * time.Minute, - RefreshTokenTTL: time.Hour, - } - - dbStore := store.GetInstance(cfg) - authService := services.NewAuthService(dbStore, cfg) - oauthService := services.NewOAuthService(dbStore, authService, cfg) - problemService := services.NewProblemService(dbStore) - executionService := services.NewExecutionService(dbStore, cfg) - submissionService := services.NewSubmissionService(dbStore, executionService) - aiService := services.NewAIService(dbStore) - userService := services.NewUserService(dbStore) - workspaceService := services.NewWorkspaceService(dbStore) +type testApplication struct { + router http.Handler + repository *store.Store + executions *services.ExecutionService + oauth *services.OAuthService +} - apiHandlers := handlers.NewHandlers(authService, oauthService, problemService, executionService, submissionService, aiService, userService, workspaceService, cfg) +func setupTestApplication(t *testing.T) testApplication { + t.Helper() + cfg := &config.Config{ + Port: 5050, + JWTSecret: "test-secret-that-is-long-enough-for-tests", + SandboxTempDir: t.TempDir(), + AccessTokenTTL: 15 * time.Minute, + RefreshTokenTTL: time.Hour, + AllowedOrigins: []string{"http://localhost:5173"}, + MaxRequestBytes: 2 << 20, + ExecutionEnabled: true, + ExecutionRunner: "local", + ExecutionQueueCapacity: 4, + ExecutionWorkerCount: 1, + ExecutionTimeoutSec: 2, + ExecutionMaxFileCount: 20, + ExecutionMaxOutputBytes: 4096, + FrontendURL: "http://localhost:5173", + GoogleClientID: "test-google-client-id", + GoogleClientSecret: "test-google-client-secret", + GoogleRedirectURL: "http://localhost:5050/api/auth/oauth/google/callback", + } - r := chi.NewRouter() - r.Route("/api", func(api chi.Router) { - api.Get("/health", apiHandlers.HealthCheck) - api.Post("/auth/login", apiHandlers.Login) - api.Get("/auth/oauth/{provider}", apiHandlers.InitiateOAuth) - api.Get("/auth/oauth/{provider}/callback", apiHandlers.OAuthCallback) - api.Get("/problems", apiHandlers.ListProblems) - api.Post("/execute/run", apiHandlers.RunCode) - api.Post("/execute/submit", apiHandlers.SubmitCode) - }) + repository := store.NewMemoryStore() + authService := services.NewAuthService(repository, cfg) + oauthService := services.NewOAuthService(repository, authService, cfg) + problemService := services.NewProblemService(repository) + executionService := services.NewExecutionService(repository, cfg) + submissionService := services.NewSubmissionService(repository) + executionService.SetSubmissionRecorder(submissionService) + aiService := services.NewAIService(repository, cfg) + userService := services.NewUserService(repository) + workspaceService := services.NewWorkspaceService(repository) - return r + apiHandlers := handlers.NewHandlers(authService, oauthService, problemService, executionService, submissionService, aiService, userService, workspaceService, cfg) + authMiddleware := handlers.NewMiddleware(cfg, authService) + app := testApplication{ + router: httpapi.NewRouter(cfg, apiHandlers, authMiddleware), + repository: repository, + executions: executionService, + oauth: oauthService, + } + t.Cleanup(executionService.Close) + return app } func TestRefreshTokenRotationAndLogout(t *testing.T) { cfg := &config.Config{ - JWTSecret: "auth-test-secret", + JWTSecret: "auth-test-secret-that-is-long-enough", AccessTokenTTL: 15 * time.Minute, RefreshTokenTTL: time.Hour, } - auth := services.NewAuthService(store.GetInstance(cfg), cfg) + auth := services.NewAuthService(store.NewMemoryStore(), cfg) uniqueEmail := "candidate-" + time.Now().Format("20060102150405.000000000") + "@example.com" created, err := auth.Register("Candidate", uniqueEmail, "correct-horse-battery1", models.RoleAdmin) @@ -91,66 +107,184 @@ func TestRefreshTokenRotationAndLogout(t *testing.T) { } func TestHealthCheck(t *testing.T) { - router := setupTestRouter() - req, _ := http.NewRequest("GET", "/api/health", nil) - rr := httptest.NewRecorder() - router.ServeHTTP(rr, req) - - if rr.Code != http.StatusOK { - t.Errorf("Expected status 200, got %d", rr.Code) + app := setupTestApplication(t) + request := httptest.NewRequest(http.MethodGet, "/api/health", nil) + response := httptest.NewRecorder() + app.router.ServeHTTP(response, request) + if response.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", response.Code) } } func TestListProblems(t *testing.T) { - router := setupTestRouter() - req, _ := http.NewRequest("GET", "/api/problems", nil) - rr := httptest.NewRecorder() - router.ServeHTTP(rr, req) - - if rr.Code != http.StatusOK { - t.Errorf("Expected status 200, got %d", rr.Code) + app := setupTestApplication(t) + request := httptest.NewRequest(http.MethodGet, "/api/problems", nil) + response := httptest.NewRecorder() + app.router.ServeHTTP(response, request) + if response.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d: %s", response.Code, response.Body.String()) } - var res handlers.APIResponse - _ = json.NewDecoder(rr.Body).Decode(&res) - if !res.Success { - t.Errorf("Expected success to be true") + var envelope handlers.APIResponse + if err := json.NewDecoder(response.Body).Decode(&envelope); err != nil { + t.Fatalf("decode response: %v", err) + } + if !envelope.Success { + t.Fatal("expected successful problem response") } } -func TestCodeExecution(t *testing.T) { - router := setupTestRouter() - +func TestExecutionRequiresAuthenticationAndAcceptsBearer(t *testing.T) { + app := setupTestApplication(t) payload := models.ExecutionRequest{ ProblemID: "parking-lot", Language: models.LangJava, - Files: []models.FileNode{ - { - ID: "main", - Name: "Main.java", - Path: "src/Main.java", - IsDirectory: false, - Language: models.LangJava, - Content: "public class Main { public static void main(String[] args) { System.out.println(\"Test Run\"); } }", - }, - }, + Files: []models.FileNode{{ + ID: "main", + Name: "Main.java", + Path: "src/Main.java", + Language: models.LangJava, + Content: `public class Main { public static void main(String[] args) { System.out.println("Test Run"); } }`, + }}, + } + body, err := json.Marshal(payload) + if err != nil { + t.Fatal(err) + } + + anonymous := httptest.NewRequest(http.MethodPost, "/api/execute/run", bytes.NewReader(body)) + anonymous.Header.Set("Content-Type", "application/json") + anonymousResponse := httptest.NewRecorder() + app.router.ServeHTTP(anonymousResponse, anonymous) + if anonymousResponse.Code != http.StatusUnauthorized { + t.Fatalf("anonymous execution status=%d, want 401", anonymousResponse.Code) + } + + token := loginCandidate(t, app.router) + authenticated := httptest.NewRequest(http.MethodPost, "/api/execute/run", bytes.NewReader(body)) + authenticated.Header.Set("Content-Type", "application/json") + authenticated.Header.Set("Authorization", "Bearer "+token) + authenticatedResponse := httptest.NewRecorder() + app.router.ServeHTTP(authenticatedResponse, authenticated) + if authenticatedResponse.Code != http.StatusAccepted { + t.Fatalf("authenticated execution status=%d, want 202: %s", authenticatedResponse.Code, authenticatedResponse.Body.String()) + } + var queued struct { + Data struct { + ExecutionID string `json:"executionId"` + } `json:"data"` + } + if err := json.NewDecoder(authenticatedResponse.Body).Decode(&queued); err != nil || queued.Data.ExecutionID == "" { + t.Fatalf("accepted response did not contain executionId: err=%v", err) + } + + anonymousPoll := httptest.NewRequest(http.MethodGet, "/api/executions/"+queued.Data.ExecutionID, nil) + anonymousPollResponse := httptest.NewRecorder() + app.router.ServeHTTP(anonymousPollResponse, anonymousPoll) + if anonymousPollResponse.Code != http.StatusUnauthorized { + t.Fatalf("anonymous execution poll status=%d, want 401", anonymousPollResponse.Code) } - jsonBytes, _ := json.Marshal(payload) - req, _ := http.NewRequest("POST", "/api/execute/run", bytes.NewBuffer(jsonBytes)) - req.Header.Set("Content-Type", "application/json") - rr := httptest.NewRecorder() - router.ServeHTTP(rr, req) + ownerPoll := httptest.NewRequest(http.MethodGet, "/api/executions/"+queued.Data.ExecutionID, nil) + ownerPoll.Header.Set("Authorization", "Bearer "+token) + ownerPollResponse := httptest.NewRecorder() + app.router.ServeHTTP(ownerPollResponse, ownerPoll) + if ownerPollResponse.Code != http.StatusOK { + t.Fatalf("owner execution poll status=%d, want 200: %s", ownerPollResponse.Code, ownerPollResponse.Body.String()) + } + var ownerEnvelope struct { + Data map[string]any `json:"data"` + } + if err := json.NewDecoder(ownerPollResponse.Body).Decode(&ownerEnvelope); err != nil { + t.Fatalf("decode owner poll: %v", err) + } + if ownerEnvelope.Data["executionId"] != queued.Data.ExecutionID { + t.Fatalf("owner poll executionId=%v, want %q", ownerEnvelope.Data["executionId"], queued.Data.ExecutionID) + } + if _, legacyID := ownerEnvelope.Data["id"]; legacyID { + t.Fatal("owner poll exposed legacy id instead of executionId") + } + + otherToken := registerCandidate(t, app.router, "other@example.com") + otherPoll := httptest.NewRequest(http.MethodGet, "/api/executions/"+queued.Data.ExecutionID, nil) + otherPoll.Header.Set("Authorization", "Bearer "+otherToken) + otherPollResponse := httptest.NewRecorder() + app.router.ServeHTTP(otherPollResponse, otherPoll) + if otherPollResponse.Code != http.StatusNotFound { + t.Fatalf("other user's execution poll status=%d, want 404", otherPollResponse.Code) + } +} + +func TestCandidateCannotAccessAdminOperationsOrMetrics(t *testing.T) { + app := setupTestApplication(t) + token := loginCandidate(t, app.router) + + metrics := httptest.NewRequest(http.MethodGet, "/api/metrics", nil) + metrics.Header.Set("Authorization", "Bearer "+token) + metricsResponse := httptest.NewRecorder() + app.router.ServeHTTP(metricsResponse, metrics) + if metricsResponse.Code != http.StatusForbidden { + t.Fatalf("candidate metrics status=%d, want 403", metricsResponse.Code) + } - if rr.Code != http.StatusAccepted { - t.Errorf("Expected status 202, got %d", rr.Code) + adminMutation := httptest.NewRequest(http.MethodPost, "/api/admin/problems/", bytes.NewBufferString(`{}`)) + adminMutation.Header.Set("Content-Type", "application/json") + adminMutation.Header.Set("Authorization", "Bearer "+token) + adminMutationResponse := httptest.NewRecorder() + app.router.ServeHTTP(adminMutationResponse, adminMutation) + if adminMutationResponse.Code != http.StatusForbidden { + t.Fatalf("candidate admin mutation status=%d, want 403", adminMutationResponse.Code) } +} - var res handlers.APIResponse - if err := json.NewDecoder(rr.Body).Decode(&res); err != nil || !res.Success { - t.Fatalf("expected successful accepted response, got body=%s err=%v", rr.Body.String(), err) +func loginCandidate(t *testing.T, router http.Handler) string { + t.Helper() + request := httptest.NewRequest(http.MethodPost, "/api/auth/login", bytes.NewBufferString(`{"email":"candidate@google.com","password":"pass123"}`)) + request.Header.Set("Content-Type", "application/json") + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + if response.Code != http.StatusOK { + t.Fatalf("login status=%d: %s", response.Code, response.Body.String()) + } + var envelope struct { + Success bool `json:"success"` + Data struct { + Token string `json:"token"` + } `json:"data"` + } + if err := json.NewDecoder(response.Body).Decode(&envelope); err != nil { + t.Fatalf("decode login: %v", err) + } + if !envelope.Success || envelope.Data.Token == "" { + t.Fatal("login did not return an access token") + } + return envelope.Data.Token +} + +func registerCandidate(t *testing.T, router http.Handler, email string) string { + t.Helper() + payload, err := json.Marshal(map[string]string{ + "name": "Other Candidate", + "email": email, + "password": "password123", + }) + if err != nil { + t.Fatal(err) + } + request := httptest.NewRequest(http.MethodPost, "/api/auth/register", bytes.NewReader(payload)) + request.Header.Set("Content-Type", "application/json") + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + if response.Code != http.StatusCreated { + t.Fatalf("register status=%d: %s", response.Code, response.Body.String()) + } + var envelope struct { + Data struct { + Token string `json:"token"` + } `json:"data"` } - if _, ok := res.Data.(map[string]any)["executionId"]; !ok { - t.Fatal("expected executionId in response") + if err := json.NewDecoder(response.Body).Decode(&envelope); err != nil || envelope.Data.Token == "" { + t.Fatalf("register did not return token: err=%v", err) } + return envelope.Data.Token } diff --git a/interviewhub-backend-go/test/oauth_test.go b/interviewhub-backend-go/test/oauth_test.go index 62260485..57e22a99 100644 --- a/interviewhub-backend-go/test/oauth_test.go +++ b/interviewhub-backend-go/test/oauth_test.go @@ -1,6 +1,8 @@ package test import ( + "bytes" + "encoding/json" "net/http" "net/http/httptest" "net/url" @@ -13,13 +15,47 @@ import ( "github.com/interviewhub/backend/internal/store" ) +func initiateGoogleOAuth(t *testing.T, app testApplication) (string, *http.Cookie) { + t.Helper() + req := httptest.NewRequest(http.MethodGet, "/api/auth/oauth/google?format=json", nil) + req.Header.Set("Accept", "application/json") + recorder := httptest.NewRecorder() + app.router.ServeHTTP(recorder, req) + if recorder.Code != http.StatusOK { + t.Fatalf("OAuth initiation status=%d body=%s", recorder.Code, recorder.Body.String()) + } + var response struct { + Data struct { + URL string `json:"url"` + } `json:"data"` + } + if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil { + t.Fatal(err) + } + authURL, err := url.Parse(response.Data.URL) + if err != nil { + t.Fatal(err) + } + state := authURL.Query().Get("state") + if state == "" { + t.Fatal("authorization URL did not contain state") + } + for _, cookie := range recorder.Result().Cookies() { + if cookie.Name == "judgemycode_oauth_state" { + return state, cookie + } + } + t.Fatal("OAuth initiation did not set the state cookie") + return "", nil +} + func TestOAuthStateGenerationAndValidation(t *testing.T) { cfg := &config.Config{ JWTSecret: "production-strength-jwt-secret-32-chars-long!", AccessTokenTTL: 15 * time.Minute, RefreshTokenTTL: 24 * time.Hour, } - dbStore := store.GetInstance(cfg) + dbStore := store.NewMemoryStore() authService := services.NewAuthService(dbStore, cfg) oauthService := services.NewOAuthService(dbStore, authService, cfg) @@ -37,13 +73,25 @@ func TestOAuthStateGenerationAndValidation(t *testing.T) { t.Fatal("ValidateState should return true for valid google state") } - // Case insensitive provider check - if !oauthService.ValidateState("GOOGLE", googleState) { + // Accepted states are one-time credentials and cannot be replayed. + if oauthService.ValidateState("google", googleState) { + t.Fatal("ValidateState should reject a replayed state") + } + + caseInsensitiveState, err := oauthService.GenerateState("google") + if err != nil { + t.Fatalf("GenerateState for case-insensitive check failed: %v", err) + } + if !oauthService.ValidateState("GOOGLE", caseInsensitiveState) { t.Fatal("ValidateState should be case-insensitive for provider") } // 3. Validating with mismatched provider should fail - if oauthService.ValidateState("github", googleState) { + mismatchState, err := oauthService.GenerateState("google") + if err != nil { + t.Fatalf("GenerateState for provider mismatch failed: %v", err) + } + if oauthService.ValidateState("github", mismatchState) { t.Fatal("ValidateState should fail when provider mismatches") } @@ -74,7 +122,7 @@ func TestOAuthAuthorizationURLGeneration(t *testing.T) { LinkedinClientSecret: "linkedin-secret-def", LinkedinRedirectURL: "http://localhost:5050/api/auth/oauth/linkedin/callback", } - dbStore := store.GetInstance(cfg) + dbStore := store.NewMemoryStore() authService := services.NewAuthService(dbStore, cfg) oauthService := services.NewOAuthService(dbStore, authService, cfg) @@ -131,7 +179,7 @@ func TestOAuthUnconfiguredProviderError(t *testing.T) { JWTSecret: "production-strength-jwt-secret-32-chars-long!", // No OAuth credentials set } - dbStore := store.GetInstance(cfg) + dbStore := store.NewMemoryStore() authService := services.NewAuthService(dbStore, cfg) oauthService := services.NewOAuthService(dbStore, authService, cfg) @@ -152,12 +200,12 @@ func TestOAuthUnconfiguredProviderError(t *testing.T) { } func TestOAuthCallbackMissingParameters(t *testing.T) { - router := setupTestRouter() + app := setupTestApplication(t) // Callback without code and state should redirect to frontend with error query param req, _ := http.NewRequest("GET", "/api/auth/oauth/google/callback", nil) rr := httptest.NewRecorder() - router.ServeHTTP(rr, req) + app.router.ServeHTTP(rr, req) if rr.Code != http.StatusTemporaryRedirect { t.Errorf("expected 307 temporary redirect, got %d", rr.Code) @@ -167,3 +215,77 @@ func TestOAuthCallbackMissingParameters(t *testing.T) { t.Fatalf("expected oauth_error in redirect location, got: %s", location) } } + +func TestOAuthBrowserStateCookieAndBinding(t *testing.T) { + app := setupTestApplication(t) + state, stateCookie := initiateGoogleOAuth(t, app) + if !stateCookie.HttpOnly || stateCookie.SameSite != http.SameSiteLaxMode || stateCookie.Path != "/api/auth/oauth/" || stateCookie.Value != state { + t.Fatalf("unsafe OAuth state cookie: %+v", stateCookie) + } + + callbackURL := "/api/auth/oauth/google/callback?code=must-not-be-exchanged&state=" + url.QueryEscape(state) + for _, testCase := range []struct { + name string + cookie *http.Cookie + }{ + {name: "missing cookie"}, + {name: "mismatched cookie", cookie: &http.Cookie{Name: stateCookie.Name, Value: "wrong-state", Path: stateCookie.Path}}, + } { + t.Run(testCase.name+" callback", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, callbackURL, nil) + if testCase.cookie != nil { + req.AddCookie(testCase.cookie) + } + recorder := httptest.NewRecorder() + app.router.ServeHTTP(recorder, req) + if recorder.Code != http.StatusTemporaryRedirect || !strings.Contains(recorder.Header().Get("Location"), "session+mismatch") { + t.Fatalf("callback status=%d location=%s", recorder.Code, recorder.Header().Get("Location")) + } + }) + + t.Run(testCase.name+" token exchange", func(t *testing.T) { + body, _ := json.Marshal(map[string]string{"code": "must-not-be-exchanged", "state": state}) + req := httptest.NewRequest(http.MethodPost, "/api/auth/oauth/google/token", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + if testCase.cookie != nil { + req.AddCookie(testCase.cookie) + } + recorder := httptest.NewRecorder() + app.router.ServeHTTP(recorder, req) + if recorder.Code != http.StatusUnauthorized || !strings.Contains(recorder.Body.String(), "session mismatch") { + t.Fatalf("token status=%d body=%s", recorder.Code, recorder.Body.String()) + } + }) + } +} + +func TestOAuthHandlersDoNotReflectProviderOrServiceErrors(t *testing.T) { + app := setupTestApplication(t) + + providerState, err := app.oauth.GenerateState("google") + if err != nil { + t.Fatal(err) + } + providerRaw := "ATTACKER_CONTROLLED_PROVIDER_DESCRIPTION" + providerURL := "/api/auth/oauth/google/callback?error=access_denied&error_description=" + url.QueryEscape(providerRaw) + "&state=" + url.QueryEscape(providerState) + providerRequest := httptest.NewRequest(http.MethodGet, providerURL, nil) + providerRequest.AddCookie(&http.Cookie{Name: "judgemycode_oauth_state", Value: providerState, Path: "/api/auth/oauth/"}) + providerRecorder := httptest.NewRecorder() + app.router.ServeHTTP(providerRecorder, providerRequest) + if location := providerRecorder.Header().Get("Location"); strings.Contains(location, providerRaw) || !strings.Contains(location, "Sign-in+failed") { + t.Fatalf("provider error was reflected or not generalized: %s", location) + } + + serviceState, err := app.oauth.GenerateState("facebook") + if err != nil { + t.Fatal(err) + } + serviceRequest := httptest.NewRequest(http.MethodGet, "/api/auth/oauth/facebook/callback?code=no-network&state="+url.QueryEscape(serviceState), nil) + serviceRequest.AddCookie(&http.Cookie{Name: "judgemycode_oauth_state", Value: serviceState, Path: "/api/auth/oauth/"}) + serviceRecorder := httptest.NewRecorder() + app.router.ServeHTTP(serviceRecorder, serviceRequest) + location := serviceRecorder.Header().Get("Location") + if strings.Contains(location, "unsupported") || !strings.Contains(location, "Sign-in+failed") { + t.Fatalf("service error was reflected or not generalized: %s", location) + } +} diff --git a/interviewhub-backend-go/test/problems.json b/interviewhub-backend-go/test/problems.json deleted file mode 100644 index 65ff08a1..00000000 --- a/interviewhub-backend-go/test/problems.json +++ /dev/null @@ -1,1354 +0,0 @@ -[ - { - "id": "parking-lot", - "title": "Parking Lot System", - "subtitle": "Design a scalable multi-floor parking lot with dynamic pricing and concurrency control", - "difficulty": "Medium", - "estimatedTime": "60 mins", - "companies": [ - "Amazon", - "Google", - "Uber", - "D. E. Shaw", - "Stripe" - ], - "description": "Design a scalable, object-oriented Parking Lot system capable of managing multiple parking floors, heterogeneous parking spots, dynamic vehicle types, and payment calculations.", - "functionalRequirements": [ - "Park a vehicle in an appropriate spot based on its type (Motorcycle, Compact, Large, EV).", - "Remove a vehicle from the parking lot and mark the spot as available.", - "Query available spots by floor and spot type in real-time.", - "Issue a unique time-stamped ticket upon entry with spot assignment.", - "Calculate fees upon exit using customizable pricing strategies." - ], - "nonFunctionalRequirements": [ - "Thread Safety: Concurrency safe under simultaneous entry gates.", - "Extensibility: Open/Closed Principle for new vehicle/spot types.", - "High cohesion and low coupling." - ], - "constraints": [ - "Single vehicle occupies exactly one parking spot matching its category.", - "Minimum fee charged is for 1 hour duration." - ], - "apis": [ - { - "name": "parkVehicle", - "signature": "parkVehicle(vehicle: Vehicle): Ticket", - "description": "Allocates spot and issues ticket." - }, - { - "name": "removeVehicle", - "signature": "removeVehicle(ticket: Ticket): Payment", - "description": "Frees spot and computes fee." - } - ], - "evaluationCriteria": [ - "Object-Oriented Design \u0026 Clean Architecture", - "Design Patterns (Strategy, Factory, Singleton)", - "Thread Safety \u0026 Concurrency handling" - ], - "tips": { - "title": "Key LLD Design Patterns", - "patterns": [ - "Strategy Pattern for Pricing", - "Factory Pattern for Vehicles" - ], - "details": [ - "Synchronize spot allocation to avoid double bookings." - ] - }, - "solutionExplanation": "Controller-Service-Repository architecture with ConcurrentHashMap.", - "classDiagramMermaid": "classDiagram\n class ParkingLot\n class ParkingSpot\n ParkingLot o-- ParkingSpot", - "starterFiles": { - "cpp": [ - { - "id": "src-folder", - "name": "src", - "path": "src", - "isDirectory": true, - "children": [ - { - "id": "main-java", - "name": "Main.java", - "path": "src/Main.java", - "content": "import java.util.*;\nimport models.*;\nimport services.*;\nimport controller.*;\n\npublic class Main {\n public static void main(String[] args) {\n System.out.println(\"=== Initializing Parking Lot System ===\");\n \n // 1. Initialize controller and setup 2 floors\n ParkingController controller = new ParkingController();\n controller.initializeFloors(2, 10, 20, 5);\n \n // 2. Create sample vehicles\n Vehicle car1 = new Car(\"KA-01-AB-1234\");\n Vehicle bike1 = new Bike(\"KA-01-AB-5678\");\n \n // 3. Park vehicles and issue tickets\n Ticket ticketCar = controller.park(car1);\n System.out.println(\"Parked Car: Ticket ID = \" + ticketCar.getTicketId() + \" at Spot: \" + ticketCar.getSpotId());\n \n Ticket ticketBike = controller.park(bike1);\n System.out.println(\"Parked Bike: Ticket ID = \" + ticketBike.getTicketId() + \" at Spot: \" + ticketBike.getSpotId());\n \n // 4. Check real-time availability\n controller.displayAvailability();\n \n // 5. Unpark \u0026 process payment\n Payment payment = controller.unpark(ticketCar);\n System.out.println(\"Payment Processed: $\" + payment.getAmount() + \" | Status: \" + payment.getStatus());\n }\n}", - "isDirectory": false, - "language": "java" - }, - { - "id": "models-folder", - "name": "models", - "path": "src/models", - "isDirectory": true, - "children": [ - { - "id": "vehicle-java", - "name": "Vehicle.java", - "path": "src/models/Vehicle.java", - "content": "package models;\n\npublic abstract class Vehicle {\n private final String licensePlate;\n private final SpotType requiredSpotType;\n\n public Vehicle(String licensePlate, SpotType requiredSpotType) {\n this.licensePlate = licensePlate;\n this.requiredSpotType = requiredSpotType;\n }\n\n public String getLicensePlate() { return licensePlate; }\n public SpotType getRequiredSpotType() { return requiredSpotType; }\n}", - "isDirectory": false, - "language": "java" - }, - { - "id": "car-java", - "name": "Car.java", - "path": "src/models/Car.java", - "content": "package models;\n\npublic class Car extends Vehicle {\n public Car(String licensePlate) {\n super(licensePlate, SpotType.COMPACT);\n }\n}", - "isDirectory": false, - "language": "java" - }, - { - "id": "bike-java", - "name": "Bike.java", - "path": "src/models/Bike.java", - "content": "package models;\n\npublic class Bike extends Vehicle {\n public Bike(String licensePlate) {\n super(licensePlate, SpotType.MOTORCYCLE);\n }\n}", - "isDirectory": false, - "language": "java" - }, - { - "id": "parkingspot-java", - "name": "ParkingSpot.java", - "path": "src/models/ParkingSpot.java", - "content": "package models;\n\npublic class ParkingSpot {\n private final String spotId;\n private final int floorNumber;\n private final SpotType spotType;\n private boolean isOccupied;\n private Vehicle currentVehicle;\n\n public ParkingSpot(String spotId, int floorNumber, SpotType spotType) {\n this.spotId = spotId;\n this.floorNumber = floorNumber;\n this.spotType = spotType;\n this.isOccupied = false;\n }\n\n public synchronized boolean assignVehicle(Vehicle vehicle) {\n if (isOccupied || vehicle.getRequiredSpotType() != spotType) {\n return false;\n }\n this.currentVehicle = vehicle;\n this.isOccupied = true;\n return true;\n }\n\n public synchronized void removeVehicle() {\n this.currentVehicle = null;\n this.isOccupied = false;\n }\n\n public String getSpotId() { return spotId; }\n public int getFloorNumber() { return floorNumber; }\n public SpotType getSpotType() { return spotType; }\n public boolean isOccupied() { return isOccupied; }\n public Vehicle getCurrentVehicle() { return currentVehicle; }\n}", - "isDirectory": false, - "language": "java" - }, - { - "id": "ticket-java", - "name": "Ticket.java", - "path": "src/models/Ticket.java", - "content": "package models;\n\nimport java.time.Instant;\n\npublic class Ticket {\n private final String ticketId;\n private final String spotId;\n private final String vehiclePlate;\n private final Instant entryTime;\n\n public Ticket(String ticketId, String spotId, String vehiclePlate) {\n this.ticketId = ticketId;\n this.spotId = spotId;\n this.vehiclePlate = vehiclePlate;\n this.entryTime = Instant.now();\n }\n\n public String getTicketId() { return ticketId; }\n public String getSpotId() { return spotId; }\n public String getVehiclePlate() { return vehiclePlate; }\n public Instant getEntryTime() { return entryTime; }\n}", - "isDirectory": false, - "language": "java" - }, - { - "id": "payment-java", - "name": "Payment.java", - "path": "src/models/Payment.java", - "content": "package models;\n\npublic class Payment {\n private final String paymentId;\n private final double amount;\n private final String status;\n\n public Payment(String paymentId, double amount, String status) {\n this.paymentId = paymentId;\n this.amount = amount;\n this.status = status;\n }\n\n public String getPaymentId() { return paymentId; }\n public double getAmount() { return amount; }\n public String getStatus() { return status; }\n}", - "isDirectory": false, - "language": "java" - }, - { - "id": "spottype-java", - "name": "SpotType.java", - "path": "src/models/SpotType.java", - "content": "package models;\n\npublic enum SpotType {\n MOTORCYCLE,\n COMPACT,\n LARGE,\n EV\n}", - "isDirectory": false, - "language": "java" - } - ] - }, - { - "id": "controller-folder", - "name": "controller", - "path": "src/controller", - "isDirectory": true, - "children": [ - { - "id": "parkingcontroller-java", - "name": "ParkingController.java", - "path": "src/controller/ParkingController.java", - "content": "package controller;\n\nimport models.*;\nimport java.util.*;\nimport java.util.concurrent.ConcurrentHashMap;\n\npublic class ParkingController {\n private final Map\u003cString, ParkingSpot\u003e spots = new ConcurrentHashMap\u003c\u003e();\n private final Map\u003cString, Ticket\u003e activeTickets = new ConcurrentHashMap\u003c\u003e();\n\n public void initializeFloors(int numFloors, int moto, int compact, int large) {\n for (int f = 1; f \u003c= numFloors; f++) {\n int spotIdx = 1;\n for (int i = 0; i \u003c moto; i++) spots.put(\"F\" + f + \"-S\" + (spotIdx++), new ParkingSpot(\"F\" + f + \"-S\" + spotIdx, f, SpotType.MOTORCYCLE));\n for (int i = 0; i \u003c compact; i++) spots.put(\"F\" + f + \"-S\" + (spotIdx++), new ParkingSpot(\"F\" + f + \"-S\" + spotIdx, f, SpotType.COMPACT));\n for (int i = 0; i \u003c large; i++) spots.put(\"F\" + f + \"-S\" + (spotIdx++), new ParkingSpot(\"F\" + f + \"-S\" + spotIdx, f, SpotType.LARGE));\n }\n }\n\n public synchronized Ticket park(Vehicle vehicle) {\n for (ParkingSpot spot : spots.values()) {\n if (!spot.isOccupied() \u0026\u0026 spot.getSpotType() == vehicle.getRequiredSpotType()) {\n if (spot.assignVehicle(vehicle)) {\n String ticketId = \"TKT-\" + UUID.randomUUID().toString().substring(0, 8);\n Ticket ticket = new Ticket(ticketId, spot.getSpotId(), vehicle.getLicensePlate());\n activeTickets.put(ticketId, ticket);\n return ticket;\n }\n }\n }\n throw new IllegalStateException(\"Parking lot full for type: \" + vehicle.getRequiredSpotType());\n }\n\n public synchronized Payment unpark(Ticket ticket) {\n Ticket active = activeTickets.remove(ticket.getTicketId());\n if (active == null) throw new IllegalArgumentException(\"Invalid ticket\");\n ParkingSpot spot = spots.get(active.getSpotId());\n if (spot != null) spot.removeVehicle();\n return new Payment(\"PAY-\" + UUID.randomUUID().toString().substring(0, 8), 20.0, \"SUCCESS\");\n }\n\n public void displayAvailability() {\n long available = spots.values().stream().filter(s -\u003e !s.isOccupied()).count();\n System.out.println(\"Available Spots: \" + available + \" / \" + spots.size());\n }\n}", - "isDirectory": false, - "language": "java" - } - ] - } - ] - } - ], - "go": [ - { - "id": "src-folder", - "name": "src", - "path": "src", - "isDirectory": true, - "children": [ - { - "id": "main-java", - "name": "Main.java", - "path": "src/Main.java", - "content": "import java.util.*;\nimport models.*;\nimport services.*;\nimport controller.*;\n\npublic class Main {\n public static void main(String[] args) {\n System.out.println(\"=== Initializing Parking Lot System ===\");\n \n // 1. Initialize controller and setup 2 floors\n ParkingController controller = new ParkingController();\n controller.initializeFloors(2, 10, 20, 5);\n \n // 2. Create sample vehicles\n Vehicle car1 = new Car(\"KA-01-AB-1234\");\n Vehicle bike1 = new Bike(\"KA-01-AB-5678\");\n \n // 3. Park vehicles and issue tickets\n Ticket ticketCar = controller.park(car1);\n System.out.println(\"Parked Car: Ticket ID = \" + ticketCar.getTicketId() + \" at Spot: \" + ticketCar.getSpotId());\n \n Ticket ticketBike = controller.park(bike1);\n System.out.println(\"Parked Bike: Ticket ID = \" + ticketBike.getTicketId() + \" at Spot: \" + ticketBike.getSpotId());\n \n // 4. Check real-time availability\n controller.displayAvailability();\n \n // 5. Unpark \u0026 process payment\n Payment payment = controller.unpark(ticketCar);\n System.out.println(\"Payment Processed: $\" + payment.getAmount() + \" | Status: \" + payment.getStatus());\n }\n}", - "isDirectory": false, - "language": "java" - }, - { - "id": "models-folder", - "name": "models", - "path": "src/models", - "isDirectory": true, - "children": [ - { - "id": "vehicle-java", - "name": "Vehicle.java", - "path": "src/models/Vehicle.java", - "content": "package models;\n\npublic abstract class Vehicle {\n private final String licensePlate;\n private final SpotType requiredSpotType;\n\n public Vehicle(String licensePlate, SpotType requiredSpotType) {\n this.licensePlate = licensePlate;\n this.requiredSpotType = requiredSpotType;\n }\n\n public String getLicensePlate() { return licensePlate; }\n public SpotType getRequiredSpotType() { return requiredSpotType; }\n}", - "isDirectory": false, - "language": "java" - }, - { - "id": "car-java", - "name": "Car.java", - "path": "src/models/Car.java", - "content": "package models;\n\npublic class Car extends Vehicle {\n public Car(String licensePlate) {\n super(licensePlate, SpotType.COMPACT);\n }\n}", - "isDirectory": false, - "language": "java" - }, - { - "id": "bike-java", - "name": "Bike.java", - "path": "src/models/Bike.java", - "content": "package models;\n\npublic class Bike extends Vehicle {\n public Bike(String licensePlate) {\n super(licensePlate, SpotType.MOTORCYCLE);\n }\n}", - "isDirectory": false, - "language": "java" - }, - { - "id": "parkingspot-java", - "name": "ParkingSpot.java", - "path": "src/models/ParkingSpot.java", - "content": "package models;\n\npublic class ParkingSpot {\n private final String spotId;\n private final int floorNumber;\n private final SpotType spotType;\n private boolean isOccupied;\n private Vehicle currentVehicle;\n\n public ParkingSpot(String spotId, int floorNumber, SpotType spotType) {\n this.spotId = spotId;\n this.floorNumber = floorNumber;\n this.spotType = spotType;\n this.isOccupied = false;\n }\n\n public synchronized boolean assignVehicle(Vehicle vehicle) {\n if (isOccupied || vehicle.getRequiredSpotType() != spotType) {\n return false;\n }\n this.currentVehicle = vehicle;\n this.isOccupied = true;\n return true;\n }\n\n public synchronized void removeVehicle() {\n this.currentVehicle = null;\n this.isOccupied = false;\n }\n\n public String getSpotId() { return spotId; }\n public int getFloorNumber() { return floorNumber; }\n public SpotType getSpotType() { return spotType; }\n public boolean isOccupied() { return isOccupied; }\n public Vehicle getCurrentVehicle() { return currentVehicle; }\n}", - "isDirectory": false, - "language": "java" - }, - { - "id": "ticket-java", - "name": "Ticket.java", - "path": "src/models/Ticket.java", - "content": "package models;\n\nimport java.time.Instant;\n\npublic class Ticket {\n private final String ticketId;\n private final String spotId;\n private final String vehiclePlate;\n private final Instant entryTime;\n\n public Ticket(String ticketId, String spotId, String vehiclePlate) {\n this.ticketId = ticketId;\n this.spotId = spotId;\n this.vehiclePlate = vehiclePlate;\n this.entryTime = Instant.now();\n }\n\n public String getTicketId() { return ticketId; }\n public String getSpotId() { return spotId; }\n public String getVehiclePlate() { return vehiclePlate; }\n public Instant getEntryTime() { return entryTime; }\n}", - "isDirectory": false, - "language": "java" - }, - { - "id": "payment-java", - "name": "Payment.java", - "path": "src/models/Payment.java", - "content": "package models;\n\npublic class Payment {\n private final String paymentId;\n private final double amount;\n private final String status;\n\n public Payment(String paymentId, double amount, String status) {\n this.paymentId = paymentId;\n this.amount = amount;\n this.status = status;\n }\n\n public String getPaymentId() { return paymentId; }\n public double getAmount() { return amount; }\n public String getStatus() { return status; }\n}", - "isDirectory": false, - "language": "java" - }, - { - "id": "spottype-java", - "name": "SpotType.java", - "path": "src/models/SpotType.java", - "content": "package models;\n\npublic enum SpotType {\n MOTORCYCLE,\n COMPACT,\n LARGE,\n EV\n}", - "isDirectory": false, - "language": "java" - } - ] - }, - { - "id": "controller-folder", - "name": "controller", - "path": "src/controller", - "isDirectory": true, - "children": [ - { - "id": "parkingcontroller-java", - "name": "ParkingController.java", - "path": "src/controller/ParkingController.java", - "content": "package controller;\n\nimport models.*;\nimport java.util.*;\nimport java.util.concurrent.ConcurrentHashMap;\n\npublic class ParkingController {\n private final Map\u003cString, ParkingSpot\u003e spots = new ConcurrentHashMap\u003c\u003e();\n private final Map\u003cString, Ticket\u003e activeTickets = new ConcurrentHashMap\u003c\u003e();\n\n public void initializeFloors(int numFloors, int moto, int compact, int large) {\n for (int f = 1; f \u003c= numFloors; f++) {\n int spotIdx = 1;\n for (int i = 0; i \u003c moto; i++) spots.put(\"F\" + f + \"-S\" + (spotIdx++), new ParkingSpot(\"F\" + f + \"-S\" + spotIdx, f, SpotType.MOTORCYCLE));\n for (int i = 0; i \u003c compact; i++) spots.put(\"F\" + f + \"-S\" + (spotIdx++), new ParkingSpot(\"F\" + f + \"-S\" + spotIdx, f, SpotType.COMPACT));\n for (int i = 0; i \u003c large; i++) spots.put(\"F\" + f + \"-S\" + (spotIdx++), new ParkingSpot(\"F\" + f + \"-S\" + spotIdx, f, SpotType.LARGE));\n }\n }\n\n public synchronized Ticket park(Vehicle vehicle) {\n for (ParkingSpot spot : spots.values()) {\n if (!spot.isOccupied() \u0026\u0026 spot.getSpotType() == vehicle.getRequiredSpotType()) {\n if (spot.assignVehicle(vehicle)) {\n String ticketId = \"TKT-\" + UUID.randomUUID().toString().substring(0, 8);\n Ticket ticket = new Ticket(ticketId, spot.getSpotId(), vehicle.getLicensePlate());\n activeTickets.put(ticketId, ticket);\n return ticket;\n }\n }\n }\n throw new IllegalStateException(\"Parking lot full for type: \" + vehicle.getRequiredSpotType());\n }\n\n public synchronized Payment unpark(Ticket ticket) {\n Ticket active = activeTickets.remove(ticket.getTicketId());\n if (active == null) throw new IllegalArgumentException(\"Invalid ticket\");\n ParkingSpot spot = spots.get(active.getSpotId());\n if (spot != null) spot.removeVehicle();\n return new Payment(\"PAY-\" + UUID.randomUUID().toString().substring(0, 8), 20.0, \"SUCCESS\");\n }\n\n public void displayAvailability() {\n long available = spots.values().stream().filter(s -\u003e !s.isOccupied()).count();\n System.out.println(\"Available Spots: \" + available + \" / \" + spots.size());\n }\n}", - "isDirectory": false, - "language": "java" - } - ] - } - ] - } - ], - "java": [ - { - "id": "src-folder", - "name": "src", - "path": "src", - "isDirectory": true, - "children": [ - { - "id": "main-java", - "name": "Main.java", - "path": "src/Main.java", - "content": "import java.util.*;\nimport models.*;\nimport services.*;\nimport controller.*;\n\npublic class Main {\n public static void main(String[] args) {\n System.out.println(\"=== Initializing Parking Lot System ===\");\n \n // 1. Initialize controller and setup 2 floors\n ParkingController controller = new ParkingController();\n controller.initializeFloors(2, 10, 20, 5);\n \n // 2. Create sample vehicles\n Vehicle car1 = new Car(\"KA-01-AB-1234\");\n Vehicle bike1 = new Bike(\"KA-01-AB-5678\");\n \n // 3. Park vehicles and issue tickets\n Ticket ticketCar = controller.park(car1);\n System.out.println(\"Parked Car: Ticket ID = \" + ticketCar.getTicketId() + \" at Spot: \" + ticketCar.getSpotId());\n \n Ticket ticketBike = controller.park(bike1);\n System.out.println(\"Parked Bike: Ticket ID = \" + ticketBike.getTicketId() + \" at Spot: \" + ticketBike.getSpotId());\n \n // 4. Check real-time availability\n controller.displayAvailability();\n \n // 5. Unpark \u0026 process payment\n Payment payment = controller.unpark(ticketCar);\n System.out.println(\"Payment Processed: $\" + payment.getAmount() + \" | Status: \" + payment.getStatus());\n }\n}", - "isDirectory": false, - "language": "java" - }, - { - "id": "models-folder", - "name": "models", - "path": "src/models", - "isDirectory": true, - "children": [ - { - "id": "vehicle-java", - "name": "Vehicle.java", - "path": "src/models/Vehicle.java", - "content": "package models;\n\npublic abstract class Vehicle {\n private final String licensePlate;\n private final SpotType requiredSpotType;\n\n public Vehicle(String licensePlate, SpotType requiredSpotType) {\n this.licensePlate = licensePlate;\n this.requiredSpotType = requiredSpotType;\n }\n\n public String getLicensePlate() { return licensePlate; }\n public SpotType getRequiredSpotType() { return requiredSpotType; }\n}", - "isDirectory": false, - "language": "java" - }, - { - "id": "car-java", - "name": "Car.java", - "path": "src/models/Car.java", - "content": "package models;\n\npublic class Car extends Vehicle {\n public Car(String licensePlate) {\n super(licensePlate, SpotType.COMPACT);\n }\n}", - "isDirectory": false, - "language": "java" - }, - { - "id": "bike-java", - "name": "Bike.java", - "path": "src/models/Bike.java", - "content": "package models;\n\npublic class Bike extends Vehicle {\n public Bike(String licensePlate) {\n super(licensePlate, SpotType.MOTORCYCLE);\n }\n}", - "isDirectory": false, - "language": "java" - }, - { - "id": "parkingspot-java", - "name": "ParkingSpot.java", - "path": "src/models/ParkingSpot.java", - "content": "package models;\n\npublic class ParkingSpot {\n private final String spotId;\n private final int floorNumber;\n private final SpotType spotType;\n private boolean isOccupied;\n private Vehicle currentVehicle;\n\n public ParkingSpot(String spotId, int floorNumber, SpotType spotType) {\n this.spotId = spotId;\n this.floorNumber = floorNumber;\n this.spotType = spotType;\n this.isOccupied = false;\n }\n\n public synchronized boolean assignVehicle(Vehicle vehicle) {\n if (isOccupied || vehicle.getRequiredSpotType() != spotType) {\n return false;\n }\n this.currentVehicle = vehicle;\n this.isOccupied = true;\n return true;\n }\n\n public synchronized void removeVehicle() {\n this.currentVehicle = null;\n this.isOccupied = false;\n }\n\n public String getSpotId() { return spotId; }\n public int getFloorNumber() { return floorNumber; }\n public SpotType getSpotType() { return spotType; }\n public boolean isOccupied() { return isOccupied; }\n public Vehicle getCurrentVehicle() { return currentVehicle; }\n}", - "isDirectory": false, - "language": "java" - }, - { - "id": "ticket-java", - "name": "Ticket.java", - "path": "src/models/Ticket.java", - "content": "package models;\n\nimport java.time.Instant;\n\npublic class Ticket {\n private final String ticketId;\n private final String spotId;\n private final String vehiclePlate;\n private final Instant entryTime;\n\n public Ticket(String ticketId, String spotId, String vehiclePlate) {\n this.ticketId = ticketId;\n this.spotId = spotId;\n this.vehiclePlate = vehiclePlate;\n this.entryTime = Instant.now();\n }\n\n public String getTicketId() { return ticketId; }\n public String getSpotId() { return spotId; }\n public String getVehiclePlate() { return vehiclePlate; }\n public Instant getEntryTime() { return entryTime; }\n}", - "isDirectory": false, - "language": "java" - }, - { - "id": "payment-java", - "name": "Payment.java", - "path": "src/models/Payment.java", - "content": "package models;\n\npublic class Payment {\n private final String paymentId;\n private final double amount;\n private final String status;\n\n public Payment(String paymentId, double amount, String status) {\n this.paymentId = paymentId;\n this.amount = amount;\n this.status = status;\n }\n\n public String getPaymentId() { return paymentId; }\n public double getAmount() { return amount; }\n public String getStatus() { return status; }\n}", - "isDirectory": false, - "language": "java" - }, - { - "id": "spottype-java", - "name": "SpotType.java", - "path": "src/models/SpotType.java", - "content": "package models;\n\npublic enum SpotType {\n MOTORCYCLE,\n COMPACT,\n LARGE,\n EV\n}", - "isDirectory": false, - "language": "java" - } - ] - }, - { - "id": "controller-folder", - "name": "controller", - "path": "src/controller", - "isDirectory": true, - "children": [ - { - "id": "parkingcontroller-java", - "name": "ParkingController.java", - "path": "src/controller/ParkingController.java", - "content": "package controller;\n\nimport models.*;\nimport java.util.*;\nimport java.util.concurrent.ConcurrentHashMap;\n\npublic class ParkingController {\n private final Map\u003cString, ParkingSpot\u003e spots = new ConcurrentHashMap\u003c\u003e();\n private final Map\u003cString, Ticket\u003e activeTickets = new ConcurrentHashMap\u003c\u003e();\n\n public void initializeFloors(int numFloors, int moto, int compact, int large) {\n for (int f = 1; f \u003c= numFloors; f++) {\n int spotIdx = 1;\n for (int i = 0; i \u003c moto; i++) spots.put(\"F\" + f + \"-S\" + (spotIdx++), new ParkingSpot(\"F\" + f + \"-S\" + spotIdx, f, SpotType.MOTORCYCLE));\n for (int i = 0; i \u003c compact; i++) spots.put(\"F\" + f + \"-S\" + (spotIdx++), new ParkingSpot(\"F\" + f + \"-S\" + spotIdx, f, SpotType.COMPACT));\n for (int i = 0; i \u003c large; i++) spots.put(\"F\" + f + \"-S\" + (spotIdx++), new ParkingSpot(\"F\" + f + \"-S\" + spotIdx, f, SpotType.LARGE));\n }\n }\n\n public synchronized Ticket park(Vehicle vehicle) {\n for (ParkingSpot spot : spots.values()) {\n if (!spot.isOccupied() \u0026\u0026 spot.getSpotType() == vehicle.getRequiredSpotType()) {\n if (spot.assignVehicle(vehicle)) {\n String ticketId = \"TKT-\" + UUID.randomUUID().toString().substring(0, 8);\n Ticket ticket = new Ticket(ticketId, spot.getSpotId(), vehicle.getLicensePlate());\n activeTickets.put(ticketId, ticket);\n return ticket;\n }\n }\n }\n throw new IllegalStateException(\"Parking lot full for type: \" + vehicle.getRequiredSpotType());\n }\n\n public synchronized Payment unpark(Ticket ticket) {\n Ticket active = activeTickets.remove(ticket.getTicketId());\n if (active == null) throw new IllegalArgumentException(\"Invalid ticket\");\n ParkingSpot spot = spots.get(active.getSpotId());\n if (spot != null) spot.removeVehicle();\n return new Payment(\"PAY-\" + UUID.randomUUID().toString().substring(0, 8), 20.0, \"SUCCESS\");\n }\n\n public void displayAvailability() {\n long available = spots.values().stream().filter(s -\u003e !s.isOccupied()).count();\n System.out.println(\"Available Spots: \" + available + \" / \" + spots.size());\n }\n}", - "isDirectory": false, - "language": "java" - } - ] - } - ] - } - ], - "python": [ - { - "id": "src-folder", - "name": "src", - "path": "src", - "isDirectory": true, - "children": [ - { - "id": "main-java", - "name": "Main.java", - "path": "src/Main.java", - "content": "import java.util.*;\nimport models.*;\nimport services.*;\nimport controller.*;\n\npublic class Main {\n public static void main(String[] args) {\n System.out.println(\"=== Initializing Parking Lot System ===\");\n \n // 1. Initialize controller and setup 2 floors\n ParkingController controller = new ParkingController();\n controller.initializeFloors(2, 10, 20, 5);\n \n // 2. Create sample vehicles\n Vehicle car1 = new Car(\"KA-01-AB-1234\");\n Vehicle bike1 = new Bike(\"KA-01-AB-5678\");\n \n // 3. Park vehicles and issue tickets\n Ticket ticketCar = controller.park(car1);\n System.out.println(\"Parked Car: Ticket ID = \" + ticketCar.getTicketId() + \" at Spot: \" + ticketCar.getSpotId());\n \n Ticket ticketBike = controller.park(bike1);\n System.out.println(\"Parked Bike: Ticket ID = \" + ticketBike.getTicketId() + \" at Spot: \" + ticketBike.getSpotId());\n \n // 4. Check real-time availability\n controller.displayAvailability();\n \n // 5. Unpark \u0026 process payment\n Payment payment = controller.unpark(ticketCar);\n System.out.println(\"Payment Processed: $\" + payment.getAmount() + \" | Status: \" + payment.getStatus());\n }\n}", - "isDirectory": false, - "language": "java" - }, - { - "id": "models-folder", - "name": "models", - "path": "src/models", - "isDirectory": true, - "children": [ - { - "id": "vehicle-java", - "name": "Vehicle.java", - "path": "src/models/Vehicle.java", - "content": "package models;\n\npublic abstract class Vehicle {\n private final String licensePlate;\n private final SpotType requiredSpotType;\n\n public Vehicle(String licensePlate, SpotType requiredSpotType) {\n this.licensePlate = licensePlate;\n this.requiredSpotType = requiredSpotType;\n }\n\n public String getLicensePlate() { return licensePlate; }\n public SpotType getRequiredSpotType() { return requiredSpotType; }\n}", - "isDirectory": false, - "language": "java" - }, - { - "id": "car-java", - "name": "Car.java", - "path": "src/models/Car.java", - "content": "package models;\n\npublic class Car extends Vehicle {\n public Car(String licensePlate) {\n super(licensePlate, SpotType.COMPACT);\n }\n}", - "isDirectory": false, - "language": "java" - }, - { - "id": "bike-java", - "name": "Bike.java", - "path": "src/models/Bike.java", - "content": "package models;\n\npublic class Bike extends Vehicle {\n public Bike(String licensePlate) {\n super(licensePlate, SpotType.MOTORCYCLE);\n }\n}", - "isDirectory": false, - "language": "java" - }, - { - "id": "parkingspot-java", - "name": "ParkingSpot.java", - "path": "src/models/ParkingSpot.java", - "content": "package models;\n\npublic class ParkingSpot {\n private final String spotId;\n private final int floorNumber;\n private final SpotType spotType;\n private boolean isOccupied;\n private Vehicle currentVehicle;\n\n public ParkingSpot(String spotId, int floorNumber, SpotType spotType) {\n this.spotId = spotId;\n this.floorNumber = floorNumber;\n this.spotType = spotType;\n this.isOccupied = false;\n }\n\n public synchronized boolean assignVehicle(Vehicle vehicle) {\n if (isOccupied || vehicle.getRequiredSpotType() != spotType) {\n return false;\n }\n this.currentVehicle = vehicle;\n this.isOccupied = true;\n return true;\n }\n\n public synchronized void removeVehicle() {\n this.currentVehicle = null;\n this.isOccupied = false;\n }\n\n public String getSpotId() { return spotId; }\n public int getFloorNumber() { return floorNumber; }\n public SpotType getSpotType() { return spotType; }\n public boolean isOccupied() { return isOccupied; }\n public Vehicle getCurrentVehicle() { return currentVehicle; }\n}", - "isDirectory": false, - "language": "java" - }, - { - "id": "ticket-java", - "name": "Ticket.java", - "path": "src/models/Ticket.java", - "content": "package models;\n\nimport java.time.Instant;\n\npublic class Ticket {\n private final String ticketId;\n private final String spotId;\n private final String vehiclePlate;\n private final Instant entryTime;\n\n public Ticket(String ticketId, String spotId, String vehiclePlate) {\n this.ticketId = ticketId;\n this.spotId = spotId;\n this.vehiclePlate = vehiclePlate;\n this.entryTime = Instant.now();\n }\n\n public String getTicketId() { return ticketId; }\n public String getSpotId() { return spotId; }\n public String getVehiclePlate() { return vehiclePlate; }\n public Instant getEntryTime() { return entryTime; }\n}", - "isDirectory": false, - "language": "java" - }, - { - "id": "payment-java", - "name": "Payment.java", - "path": "src/models/Payment.java", - "content": "package models;\n\npublic class Payment {\n private final String paymentId;\n private final double amount;\n private final String status;\n\n public Payment(String paymentId, double amount, String status) {\n this.paymentId = paymentId;\n this.amount = amount;\n this.status = status;\n }\n\n public String getPaymentId() { return paymentId; }\n public double getAmount() { return amount; }\n public String getStatus() { return status; }\n}", - "isDirectory": false, - "language": "java" - }, - { - "id": "spottype-java", - "name": "SpotType.java", - "path": "src/models/SpotType.java", - "content": "package models;\n\npublic enum SpotType {\n MOTORCYCLE,\n COMPACT,\n LARGE,\n EV\n}", - "isDirectory": false, - "language": "java" - } - ] - }, - { - "id": "controller-folder", - "name": "controller", - "path": "src/controller", - "isDirectory": true, - "children": [ - { - "id": "parkingcontroller-java", - "name": "ParkingController.java", - "path": "src/controller/ParkingController.java", - "content": "package controller;\n\nimport models.*;\nimport java.util.*;\nimport java.util.concurrent.ConcurrentHashMap;\n\npublic class ParkingController {\n private final Map\u003cString, ParkingSpot\u003e spots = new ConcurrentHashMap\u003c\u003e();\n private final Map\u003cString, Ticket\u003e activeTickets = new ConcurrentHashMap\u003c\u003e();\n\n public void initializeFloors(int numFloors, int moto, int compact, int large) {\n for (int f = 1; f \u003c= numFloors; f++) {\n int spotIdx = 1;\n for (int i = 0; i \u003c moto; i++) spots.put(\"F\" + f + \"-S\" + (spotIdx++), new ParkingSpot(\"F\" + f + \"-S\" + spotIdx, f, SpotType.MOTORCYCLE));\n for (int i = 0; i \u003c compact; i++) spots.put(\"F\" + f + \"-S\" + (spotIdx++), new ParkingSpot(\"F\" + f + \"-S\" + spotIdx, f, SpotType.COMPACT));\n for (int i = 0; i \u003c large; i++) spots.put(\"F\" + f + \"-S\" + (spotIdx++), new ParkingSpot(\"F\" + f + \"-S\" + spotIdx, f, SpotType.LARGE));\n }\n }\n\n public synchronized Ticket park(Vehicle vehicle) {\n for (ParkingSpot spot : spots.values()) {\n if (!spot.isOccupied() \u0026\u0026 spot.getSpotType() == vehicle.getRequiredSpotType()) {\n if (spot.assignVehicle(vehicle)) {\n String ticketId = \"TKT-\" + UUID.randomUUID().toString().substring(0, 8);\n Ticket ticket = new Ticket(ticketId, spot.getSpotId(), vehicle.getLicensePlate());\n activeTickets.put(ticketId, ticket);\n return ticket;\n }\n }\n }\n throw new IllegalStateException(\"Parking lot full for type: \" + vehicle.getRequiredSpotType());\n }\n\n public synchronized Payment unpark(Ticket ticket) {\n Ticket active = activeTickets.remove(ticket.getTicketId());\n if (active == null) throw new IllegalArgumentException(\"Invalid ticket\");\n ParkingSpot spot = spots.get(active.getSpotId());\n if (spot != null) spot.removeVehicle();\n return new Payment(\"PAY-\" + UUID.randomUUID().toString().substring(0, 8), 20.0, \"SUCCESS\");\n }\n\n public void displayAvailability() {\n long available = spots.values().stream().filter(s -\u003e !s.isOccupied()).count();\n System.out.println(\"Available Spots: \" + available + \" / \" + spots.size());\n }\n}", - "isDirectory": false, - "language": "java" - } - ] - } - ] - } - ], - "typescript": [ - { - "id": "src-folder", - "name": "src", - "path": "src", - "isDirectory": true, - "children": [ - { - "id": "main-java", - "name": "Main.java", - "path": "src/Main.java", - "content": "import java.util.*;\nimport models.*;\nimport services.*;\nimport controller.*;\n\npublic class Main {\n public static void main(String[] args) {\n System.out.println(\"=== Initializing Parking Lot System ===\");\n \n // 1. Initialize controller and setup 2 floors\n ParkingController controller = new ParkingController();\n controller.initializeFloors(2, 10, 20, 5);\n \n // 2. Create sample vehicles\n Vehicle car1 = new Car(\"KA-01-AB-1234\");\n Vehicle bike1 = new Bike(\"KA-01-AB-5678\");\n \n // 3. Park vehicles and issue tickets\n Ticket ticketCar = controller.park(car1);\n System.out.println(\"Parked Car: Ticket ID = \" + ticketCar.getTicketId() + \" at Spot: \" + ticketCar.getSpotId());\n \n Ticket ticketBike = controller.park(bike1);\n System.out.println(\"Parked Bike: Ticket ID = \" + ticketBike.getTicketId() + \" at Spot: \" + ticketBike.getSpotId());\n \n // 4. Check real-time availability\n controller.displayAvailability();\n \n // 5. Unpark \u0026 process payment\n Payment payment = controller.unpark(ticketCar);\n System.out.println(\"Payment Processed: $\" + payment.getAmount() + \" | Status: \" + payment.getStatus());\n }\n}", - "isDirectory": false, - "language": "java" - }, - { - "id": "models-folder", - "name": "models", - "path": "src/models", - "isDirectory": true, - "children": [ - { - "id": "vehicle-java", - "name": "Vehicle.java", - "path": "src/models/Vehicle.java", - "content": "package models;\n\npublic abstract class Vehicle {\n private final String licensePlate;\n private final SpotType requiredSpotType;\n\n public Vehicle(String licensePlate, SpotType requiredSpotType) {\n this.licensePlate = licensePlate;\n this.requiredSpotType = requiredSpotType;\n }\n\n public String getLicensePlate() { return licensePlate; }\n public SpotType getRequiredSpotType() { return requiredSpotType; }\n}", - "isDirectory": false, - "language": "java" - }, - { - "id": "car-java", - "name": "Car.java", - "path": "src/models/Car.java", - "content": "package models;\n\npublic class Car extends Vehicle {\n public Car(String licensePlate) {\n super(licensePlate, SpotType.COMPACT);\n }\n}", - "isDirectory": false, - "language": "java" - }, - { - "id": "bike-java", - "name": "Bike.java", - "path": "src/models/Bike.java", - "content": "package models;\n\npublic class Bike extends Vehicle {\n public Bike(String licensePlate) {\n super(licensePlate, SpotType.MOTORCYCLE);\n }\n}", - "isDirectory": false, - "language": "java" - }, - { - "id": "parkingspot-java", - "name": "ParkingSpot.java", - "path": "src/models/ParkingSpot.java", - "content": "package models;\n\npublic class ParkingSpot {\n private final String spotId;\n private final int floorNumber;\n private final SpotType spotType;\n private boolean isOccupied;\n private Vehicle currentVehicle;\n\n public ParkingSpot(String spotId, int floorNumber, SpotType spotType) {\n this.spotId = spotId;\n this.floorNumber = floorNumber;\n this.spotType = spotType;\n this.isOccupied = false;\n }\n\n public synchronized boolean assignVehicle(Vehicle vehicle) {\n if (isOccupied || vehicle.getRequiredSpotType() != spotType) {\n return false;\n }\n this.currentVehicle = vehicle;\n this.isOccupied = true;\n return true;\n }\n\n public synchronized void removeVehicle() {\n this.currentVehicle = null;\n this.isOccupied = false;\n }\n\n public String getSpotId() { return spotId; }\n public int getFloorNumber() { return floorNumber; }\n public SpotType getSpotType() { return spotType; }\n public boolean isOccupied() { return isOccupied; }\n public Vehicle getCurrentVehicle() { return currentVehicle; }\n}", - "isDirectory": false, - "language": "java" - }, - { - "id": "ticket-java", - "name": "Ticket.java", - "path": "src/models/Ticket.java", - "content": "package models;\n\nimport java.time.Instant;\n\npublic class Ticket {\n private final String ticketId;\n private final String spotId;\n private final String vehiclePlate;\n private final Instant entryTime;\n\n public Ticket(String ticketId, String spotId, String vehiclePlate) {\n this.ticketId = ticketId;\n this.spotId = spotId;\n this.vehiclePlate = vehiclePlate;\n this.entryTime = Instant.now();\n }\n\n public String getTicketId() { return ticketId; }\n public String getSpotId() { return spotId; }\n public String getVehiclePlate() { return vehiclePlate; }\n public Instant getEntryTime() { return entryTime; }\n}", - "isDirectory": false, - "language": "java" - }, - { - "id": "payment-java", - "name": "Payment.java", - "path": "src/models/Payment.java", - "content": "package models;\n\npublic class Payment {\n private final String paymentId;\n private final double amount;\n private final String status;\n\n public Payment(String paymentId, double amount, String status) {\n this.paymentId = paymentId;\n this.amount = amount;\n this.status = status;\n }\n\n public String getPaymentId() { return paymentId; }\n public double getAmount() { return amount; }\n public String getStatus() { return status; }\n}", - "isDirectory": false, - "language": "java" - }, - { - "id": "spottype-java", - "name": "SpotType.java", - "path": "src/models/SpotType.java", - "content": "package models;\n\npublic enum SpotType {\n MOTORCYCLE,\n COMPACT,\n LARGE,\n EV\n}", - "isDirectory": false, - "language": "java" - } - ] - }, - { - "id": "controller-folder", - "name": "controller", - "path": "src/controller", - "isDirectory": true, - "children": [ - { - "id": "parkingcontroller-java", - "name": "ParkingController.java", - "path": "src/controller/ParkingController.java", - "content": "package controller;\n\nimport models.*;\nimport java.util.*;\nimport java.util.concurrent.ConcurrentHashMap;\n\npublic class ParkingController {\n private final Map\u003cString, ParkingSpot\u003e spots = new ConcurrentHashMap\u003c\u003e();\n private final Map\u003cString, Ticket\u003e activeTickets = new ConcurrentHashMap\u003c\u003e();\n\n public void initializeFloors(int numFloors, int moto, int compact, int large) {\n for (int f = 1; f \u003c= numFloors; f++) {\n int spotIdx = 1;\n for (int i = 0; i \u003c moto; i++) spots.put(\"F\" + f + \"-S\" + (spotIdx++), new ParkingSpot(\"F\" + f + \"-S\" + spotIdx, f, SpotType.MOTORCYCLE));\n for (int i = 0; i \u003c compact; i++) spots.put(\"F\" + f + \"-S\" + (spotIdx++), new ParkingSpot(\"F\" + f + \"-S\" + spotIdx, f, SpotType.COMPACT));\n for (int i = 0; i \u003c large; i++) spots.put(\"F\" + f + \"-S\" + (spotIdx++), new ParkingSpot(\"F\" + f + \"-S\" + spotIdx, f, SpotType.LARGE));\n }\n }\n\n public synchronized Ticket park(Vehicle vehicle) {\n for (ParkingSpot spot : spots.values()) {\n if (!spot.isOccupied() \u0026\u0026 spot.getSpotType() == vehicle.getRequiredSpotType()) {\n if (spot.assignVehicle(vehicle)) {\n String ticketId = \"TKT-\" + UUID.randomUUID().toString().substring(0, 8);\n Ticket ticket = new Ticket(ticketId, spot.getSpotId(), vehicle.getLicensePlate());\n activeTickets.put(ticketId, ticket);\n return ticket;\n }\n }\n }\n throw new IllegalStateException(\"Parking lot full for type: \" + vehicle.getRequiredSpotType());\n }\n\n public synchronized Payment unpark(Ticket ticket) {\n Ticket active = activeTickets.remove(ticket.getTicketId());\n if (active == null) throw new IllegalArgumentException(\"Invalid ticket\");\n ParkingSpot spot = spots.get(active.getSpotId());\n if (spot != null) spot.removeVehicle();\n return new Payment(\"PAY-\" + UUID.randomUUID().toString().substring(0, 8), 20.0, \"SUCCESS\");\n }\n\n public void displayAvailability() {\n long available = spots.values().stream().filter(s -\u003e !s.isOccupied()).count();\n System.out.println(\"Available Spots: \" + available + \" / \" + spots.size());\n }\n}", - "isDirectory": false, - "language": "java" - } - ] - } - ] - } - ] - }, - "testCases": [ - { - "id": "tc-1", - "name": "Park Car in Compact Spot", - "description": "Hits parkVehicle API: car is assigned a compact spot and a ticket is issued.", - "category": "functionality", - "visibility": "public", - "apiMethod": "parkVehicle", - "input": "parkVehicle(new Car(\"KA-01-AB-1234\"))", - "expectedOutput": "Ticket issued: TKT-1234 at Spot F1-S1", - "status": "idle", - "timeoutMs": 5000, - "concurrentWorkers": 1 - }, - { - "id": "tc-2", - "name": "Unpark \u0026 Compute Fee", - "description": "Hits removeVehicle API: spot is freed and payment is processed.", - "category": "functionality", - "visibility": "public", - "apiMethod": "removeVehicle", - "input": "removeVehicle(ticket)", - "expectedOutput": "Payment: $40.0 | Spot Freed: F1-S1", - "status": "idle", - "timeoutMs": 5000, - "concurrentWorkers": 1 - }, - { - "id": "tc-3", - "name": "Concurrent Entry Gates", - "description": "Hits parkVehicle concurrently from multiple gates; no double-booking of the same spot.", - "category": "concurrency", - "visibility": "hidden", - "apiMethod": "parkVehicle", - "input": "parallel parkVehicle(carA), parkVehicle(carB), parkVehicle(carC)", - "expectedOutput": "All tickets unique; no overlapping spot IDs", - "status": "idle", - "timeoutMs": 8000, - "concurrentWorkers": 8 - } - ], - "createdAt": "2026-08-07T00:55:30.607398+05:30", - "updatedAt": "2026-08-07T00:55:30.607398+05:30" - }, - { - "id": "bookmyshow", - "title": "Movie Ticket Booking (BookMyShow)", - "subtitle": "Design a high-concurrency seat reservation system with dynamic locking, shows, and payment timeouts", - "difficulty": "Hard", - "estimatedTime": "60 mins", - "companies": [ - "Google", - "Amazon", - "Uber", - "Flipkart" - ], - "description": "Design an online movie ticket booking platform that allows users to search movies, lock seats temporarily for checkout, and confirm bookings.", - "functionalRequirements": [ - "Search shows by movie and city.", - "Temporarily lock seats for checkout.", - "Confirm booking upon payment completion." - ], - "nonFunctionalRequirements": [ - "High Concurrency: Prevent race conditions.", - "Sub-20ms seat queries." - ], - "constraints": [ - "Maximum 10 seats per booking." - ], - "apis": [ - { - "name": "lockSeats", - "signature": "lockSeats(showId, seats, userId): String", - "description": "Locks seats with TTL." - }, - { - "name": "confirmBooking", - "signature": "confirmBooking(token): String", - "description": "Confirms booking." - } - ], - "evaluationCriteria": [ - "Pessimistic/Optimistic Concurrency Control", - "State Pattern for Seat Lifecycle" - ], - "tips": { - "title": "Concurrency Strategies", - "patterns": [ - "State Pattern", - "Distributed Lock / In-Memory Lock" - ], - "details": [ - "Handle expired lock tokens cleanly." - ] - }, - "solutionExplanation": "In-memory seat locking with token expiration.", - "classDiagramMermaid": "classDiagram\n class Show\n class Booking\n Show o-- Booking", - "starterFiles": { - "cpp": [ - { - "id": "src-bms-folder", - "name": "src", - "path": "src", - "isDirectory": true, - "children": [ - { - "id": "main-bms-java", - "name": "Main.java", - "path": "src/Main.java", - "content": "import java.util.*;\nimport controller.*;\n\npublic class Main {\n public static void main(String[] args) {\n System.out.println(\"=== Initializing Movie Ticket Booking System (BookMyShow) ===\");\n \n MovieBookingController controller = new MovieBookingController();\n controller.initializeDemoCatalog();\n \n // 1. Search shows\n controller.searchShows(\"Interstellar\", \"New York\");\n \n // 2. Lock Seats A1, A2\n String token = controller.lockSeats(\"SHOW-1\", List.of(\"A1\", \"A2\"), \"user-101\");\n System.out.println(\"Seats Locked Successfully. Lock Token: \" + token);\n \n // 3. Confirm Booking\n String bookingId = controller.confirmBooking(token);\n System.out.println(\"Booking Confirmed! Booking ID: \" + bookingId);\n }\n}", - "isDirectory": false, - "language": "java" - }, - { - "id": "controller-bms-folder", - "name": "controller", - "path": "src/controller", - "isDirectory": true, - "children": [ - { - "id": "moviecontroller-java", - "name": "MovieBookingController.java", - "path": "src/controller/MovieBookingController.java", - "content": "package controller;\n\nimport java.util.*;\nimport java.util.concurrent.ConcurrentHashMap;\n\npublic class MovieBookingController {\n private final Map\u003cString, String\u003e seatLocks = new ConcurrentHashMap\u003c\u003e();\n private final Set\u003cString\u003e bookedSeats = Collections.synchronizedSet(new HashSet\u003c\u003e());\n\n public void initializeDemoCatalog() {\n System.out.println(\"Loaded 2 shows for Interstellar in New York (AMC Empire 25 \u0026 Regal)\");\n }\n\n public void searchShows(String movie, String city) {\n System.out.println(\"Found Show SHOW-1 for \" + movie + \" in \" + city);\n }\n\n public synchronized String lockSeats(String showId, List\u003cString\u003e seats, String userId) {\n for (String s : seats) {\n if (bookedSeats.contains(s) || seatLocks.containsKey(s)) {\n throw new IllegalStateException(\"Seat \" + s + \" is not available\");\n }\n }\n String token = \"LCK-\" + UUID.randomUUID().toString().substring(0, 8);\n for (String s : seats) seatLocks.put(s, token);\n return token;\n }\n\n public synchronized String confirmBooking(String token) {\n List\u003cString\u003e confirmed = new ArrayList\u003c\u003e();\n seatLocks.forEach((seat, t) -\u003e {\n if (t.equals(token)) confirmed.add(seat);\n });\n if (confirmed.isEmpty()) throw new IllegalStateException(\"Invalid or expired lock token\");\n confirmed.forEach(s -\u003e {\n seatLocks.remove(s);\n bookedSeats.add(s);\n });\n return \"BKG-\" + UUID.randomUUID().toString().substring(0, 8);\n }\n}", - "isDirectory": false, - "language": "java" - } - ] - } - ] - } - ], - "go": [ - { - "id": "src-bms-folder", - "name": "src", - "path": "src", - "isDirectory": true, - "children": [ - { - "id": "main-bms-java", - "name": "Main.java", - "path": "src/Main.java", - "content": "import java.util.*;\nimport controller.*;\n\npublic class Main {\n public static void main(String[] args) {\n System.out.println(\"=== Initializing Movie Ticket Booking System (BookMyShow) ===\");\n \n MovieBookingController controller = new MovieBookingController();\n controller.initializeDemoCatalog();\n \n // 1. Search shows\n controller.searchShows(\"Interstellar\", \"New York\");\n \n // 2. Lock Seats A1, A2\n String token = controller.lockSeats(\"SHOW-1\", List.of(\"A1\", \"A2\"), \"user-101\");\n System.out.println(\"Seats Locked Successfully. Lock Token: \" + token);\n \n // 3. Confirm Booking\n String bookingId = controller.confirmBooking(token);\n System.out.println(\"Booking Confirmed! Booking ID: \" + bookingId);\n }\n}", - "isDirectory": false, - "language": "java" - }, - { - "id": "controller-bms-folder", - "name": "controller", - "path": "src/controller", - "isDirectory": true, - "children": [ - { - "id": "moviecontroller-java", - "name": "MovieBookingController.java", - "path": "src/controller/MovieBookingController.java", - "content": "package controller;\n\nimport java.util.*;\nimport java.util.concurrent.ConcurrentHashMap;\n\npublic class MovieBookingController {\n private final Map\u003cString, String\u003e seatLocks = new ConcurrentHashMap\u003c\u003e();\n private final Set\u003cString\u003e bookedSeats = Collections.synchronizedSet(new HashSet\u003c\u003e());\n\n public void initializeDemoCatalog() {\n System.out.println(\"Loaded 2 shows for Interstellar in New York (AMC Empire 25 \u0026 Regal)\");\n }\n\n public void searchShows(String movie, String city) {\n System.out.println(\"Found Show SHOW-1 for \" + movie + \" in \" + city);\n }\n\n public synchronized String lockSeats(String showId, List\u003cString\u003e seats, String userId) {\n for (String s : seats) {\n if (bookedSeats.contains(s) || seatLocks.containsKey(s)) {\n throw new IllegalStateException(\"Seat \" + s + \" is not available\");\n }\n }\n String token = \"LCK-\" + UUID.randomUUID().toString().substring(0, 8);\n for (String s : seats) seatLocks.put(s, token);\n return token;\n }\n\n public synchronized String confirmBooking(String token) {\n List\u003cString\u003e confirmed = new ArrayList\u003c\u003e();\n seatLocks.forEach((seat, t) -\u003e {\n if (t.equals(token)) confirmed.add(seat);\n });\n if (confirmed.isEmpty()) throw new IllegalStateException(\"Invalid or expired lock token\");\n confirmed.forEach(s -\u003e {\n seatLocks.remove(s);\n bookedSeats.add(s);\n });\n return \"BKG-\" + UUID.randomUUID().toString().substring(0, 8);\n }\n}", - "isDirectory": false, - "language": "java" - } - ] - } - ] - } - ], - "java": [ - { - "id": "src-bms-folder", - "name": "src", - "path": "src", - "isDirectory": true, - "children": [ - { - "id": "main-bms-java", - "name": "Main.java", - "path": "src/Main.java", - "content": "import java.util.*;\nimport controller.*;\n\npublic class Main {\n public static void main(String[] args) {\n System.out.println(\"=== Initializing Movie Ticket Booking System (BookMyShow) ===\");\n \n MovieBookingController controller = new MovieBookingController();\n controller.initializeDemoCatalog();\n \n // 1. Search shows\n controller.searchShows(\"Interstellar\", \"New York\");\n \n // 2. Lock Seats A1, A2\n String token = controller.lockSeats(\"SHOW-1\", List.of(\"A1\", \"A2\"), \"user-101\");\n System.out.println(\"Seats Locked Successfully. Lock Token: \" + token);\n \n // 3. Confirm Booking\n String bookingId = controller.confirmBooking(token);\n System.out.println(\"Booking Confirmed! Booking ID: \" + bookingId);\n }\n}", - "isDirectory": false, - "language": "java" - }, - { - "id": "controller-bms-folder", - "name": "controller", - "path": "src/controller", - "isDirectory": true, - "children": [ - { - "id": "moviecontroller-java", - "name": "MovieBookingController.java", - "path": "src/controller/MovieBookingController.java", - "content": "package controller;\n\nimport java.util.*;\nimport java.util.concurrent.ConcurrentHashMap;\n\npublic class MovieBookingController {\n private final Map\u003cString, String\u003e seatLocks = new ConcurrentHashMap\u003c\u003e();\n private final Set\u003cString\u003e bookedSeats = Collections.synchronizedSet(new HashSet\u003c\u003e());\n\n public void initializeDemoCatalog() {\n System.out.println(\"Loaded 2 shows for Interstellar in New York (AMC Empire 25 \u0026 Regal)\");\n }\n\n public void searchShows(String movie, String city) {\n System.out.println(\"Found Show SHOW-1 for \" + movie + \" in \" + city);\n }\n\n public synchronized String lockSeats(String showId, List\u003cString\u003e seats, String userId) {\n for (String s : seats) {\n if (bookedSeats.contains(s) || seatLocks.containsKey(s)) {\n throw new IllegalStateException(\"Seat \" + s + \" is not available\");\n }\n }\n String token = \"LCK-\" + UUID.randomUUID().toString().substring(0, 8);\n for (String s : seats) seatLocks.put(s, token);\n return token;\n }\n\n public synchronized String confirmBooking(String token) {\n List\u003cString\u003e confirmed = new ArrayList\u003c\u003e();\n seatLocks.forEach((seat, t) -\u003e {\n if (t.equals(token)) confirmed.add(seat);\n });\n if (confirmed.isEmpty()) throw new IllegalStateException(\"Invalid or expired lock token\");\n confirmed.forEach(s -\u003e {\n seatLocks.remove(s);\n bookedSeats.add(s);\n });\n return \"BKG-\" + UUID.randomUUID().toString().substring(0, 8);\n }\n}", - "isDirectory": false, - "language": "java" - } - ] - } - ] - } - ], - "python": [ - { - "id": "src-bms-folder", - "name": "src", - "path": "src", - "isDirectory": true, - "children": [ - { - "id": "main-bms-java", - "name": "Main.java", - "path": "src/Main.java", - "content": "import java.util.*;\nimport controller.*;\n\npublic class Main {\n public static void main(String[] args) {\n System.out.println(\"=== Initializing Movie Ticket Booking System (BookMyShow) ===\");\n \n MovieBookingController controller = new MovieBookingController();\n controller.initializeDemoCatalog();\n \n // 1. Search shows\n controller.searchShows(\"Interstellar\", \"New York\");\n \n // 2. Lock Seats A1, A2\n String token = controller.lockSeats(\"SHOW-1\", List.of(\"A1\", \"A2\"), \"user-101\");\n System.out.println(\"Seats Locked Successfully. Lock Token: \" + token);\n \n // 3. Confirm Booking\n String bookingId = controller.confirmBooking(token);\n System.out.println(\"Booking Confirmed! Booking ID: \" + bookingId);\n }\n}", - "isDirectory": false, - "language": "java" - }, - { - "id": "controller-bms-folder", - "name": "controller", - "path": "src/controller", - "isDirectory": true, - "children": [ - { - "id": "moviecontroller-java", - "name": "MovieBookingController.java", - "path": "src/controller/MovieBookingController.java", - "content": "package controller;\n\nimport java.util.*;\nimport java.util.concurrent.ConcurrentHashMap;\n\npublic class MovieBookingController {\n private final Map\u003cString, String\u003e seatLocks = new ConcurrentHashMap\u003c\u003e();\n private final Set\u003cString\u003e bookedSeats = Collections.synchronizedSet(new HashSet\u003c\u003e());\n\n public void initializeDemoCatalog() {\n System.out.println(\"Loaded 2 shows for Interstellar in New York (AMC Empire 25 \u0026 Regal)\");\n }\n\n public void searchShows(String movie, String city) {\n System.out.println(\"Found Show SHOW-1 for \" + movie + \" in \" + city);\n }\n\n public synchronized String lockSeats(String showId, List\u003cString\u003e seats, String userId) {\n for (String s : seats) {\n if (bookedSeats.contains(s) || seatLocks.containsKey(s)) {\n throw new IllegalStateException(\"Seat \" + s + \" is not available\");\n }\n }\n String token = \"LCK-\" + UUID.randomUUID().toString().substring(0, 8);\n for (String s : seats) seatLocks.put(s, token);\n return token;\n }\n\n public synchronized String confirmBooking(String token) {\n List\u003cString\u003e confirmed = new ArrayList\u003c\u003e();\n seatLocks.forEach((seat, t) -\u003e {\n if (t.equals(token)) confirmed.add(seat);\n });\n if (confirmed.isEmpty()) throw new IllegalStateException(\"Invalid or expired lock token\");\n confirmed.forEach(s -\u003e {\n seatLocks.remove(s);\n bookedSeats.add(s);\n });\n return \"BKG-\" + UUID.randomUUID().toString().substring(0, 8);\n }\n}", - "isDirectory": false, - "language": "java" - } - ] - } - ] - } - ], - "typescript": [ - { - "id": "src-bms-folder", - "name": "src", - "path": "src", - "isDirectory": true, - "children": [ - { - "id": "main-bms-java", - "name": "Main.java", - "path": "src/Main.java", - "content": "import java.util.*;\nimport controller.*;\n\npublic class Main {\n public static void main(String[] args) {\n System.out.println(\"=== Initializing Movie Ticket Booking System (BookMyShow) ===\");\n \n MovieBookingController controller = new MovieBookingController();\n controller.initializeDemoCatalog();\n \n // 1. Search shows\n controller.searchShows(\"Interstellar\", \"New York\");\n \n // 2. Lock Seats A1, A2\n String token = controller.lockSeats(\"SHOW-1\", List.of(\"A1\", \"A2\"), \"user-101\");\n System.out.println(\"Seats Locked Successfully. Lock Token: \" + token);\n \n // 3. Confirm Booking\n String bookingId = controller.confirmBooking(token);\n System.out.println(\"Booking Confirmed! Booking ID: \" + bookingId);\n }\n}", - "isDirectory": false, - "language": "java" - }, - { - "id": "controller-bms-folder", - "name": "controller", - "path": "src/controller", - "isDirectory": true, - "children": [ - { - "id": "moviecontroller-java", - "name": "MovieBookingController.java", - "path": "src/controller/MovieBookingController.java", - "content": "package controller;\n\nimport java.util.*;\nimport java.util.concurrent.ConcurrentHashMap;\n\npublic class MovieBookingController {\n private final Map\u003cString, String\u003e seatLocks = new ConcurrentHashMap\u003c\u003e();\n private final Set\u003cString\u003e bookedSeats = Collections.synchronizedSet(new HashSet\u003c\u003e());\n\n public void initializeDemoCatalog() {\n System.out.println(\"Loaded 2 shows for Interstellar in New York (AMC Empire 25 \u0026 Regal)\");\n }\n\n public void searchShows(String movie, String city) {\n System.out.println(\"Found Show SHOW-1 for \" + movie + \" in \" + city);\n }\n\n public synchronized String lockSeats(String showId, List\u003cString\u003e seats, String userId) {\n for (String s : seats) {\n if (bookedSeats.contains(s) || seatLocks.containsKey(s)) {\n throw new IllegalStateException(\"Seat \" + s + \" is not available\");\n }\n }\n String token = \"LCK-\" + UUID.randomUUID().toString().substring(0, 8);\n for (String s : seats) seatLocks.put(s, token);\n return token;\n }\n\n public synchronized String confirmBooking(String token) {\n List\u003cString\u003e confirmed = new ArrayList\u003c\u003e();\n seatLocks.forEach((seat, t) -\u003e {\n if (t.equals(token)) confirmed.add(seat);\n });\n if (confirmed.isEmpty()) throw new IllegalStateException(\"Invalid or expired lock token\");\n confirmed.forEach(s -\u003e {\n seatLocks.remove(s);\n bookedSeats.add(s);\n });\n return \"BKG-\" + UUID.randomUUID().toString().substring(0, 8);\n }\n}", - "isDirectory": false, - "language": "java" - } - ] - } - ] - } - ] - }, - "testCases": [ - { - "id": "tc-bms-1", - "name": "Lock Available Seats", - "description": "Hits lockSeats API for two free seats.", - "category": "functionality", - "visibility": "public", - "apiMethod": "lockSeats", - "input": "lockSeats(\"SHOW-1\", [\"A1\", \"A2\"], \"user-1\")", - "expectedOutput": "SUCCESS", - "status": "idle", - "timeoutMs": 5000, - "concurrentWorkers": 1 - }, - { - "id": "tc-bms-2", - "name": "Concurrent Seat Race", - "description": "Two users hit lockSeats on the same seat; only one lock succeeds.", - "category": "concurrency", - "visibility": "hidden", - "apiMethod": "lockSeats", - "input": "parallel lockSeats(SHOW-1, [A1], userA) | lockSeats(SHOW-1, [A1], userB)", - "expectedOutput": "Exactly one SUCCESS", - "status": "idle", - "timeoutMs": 8000, - "concurrentWorkers": 2 - } - ], - "createdAt": "2026-08-07T00:55:30.607398+05:30", - "updatedAt": "2026-08-07T00:55:30.607398+05:30" - }, - { - "id": "rate-limiter", - "title": "Distributed Rate Limiter", - "subtitle": "Design an extensible rate limiter supporting Token Bucket, Sliding Window, and Leaky Bucket algorithms", - "difficulty": "Medium", - "estimatedTime": "45 mins", - "companies": [ - "Stripe", - "Google", - "Uber", - "Cloudflare" - ], - "description": "Design a high-throughput, low-latency Rate Limiter component capable of throttling API requests per user, IP address, or API key.", - "functionalRequirements": [ - "Allow or throttle incoming requests based on configured quota.", - "Support dynamic configuration per subscription tier." - ], - "nonFunctionalRequirements": [ - "Sub-millisecond decision latency.", - "Thread safe concurrent token consumption." - ], - "constraints": [ - "Low memory per client." - ], - "apis": [ - { - "name": "isAllowed", - "signature": "isAllowed(clientId, tier): boolean", - "description": "Checks if request is permitted." - } - ], - "evaluationCriteria": [ - "Strategy Pattern for Token Bucket algorithm" - ], - "tips": { - "title": "Lazy Token Refill", - "patterns": [ - "Token Bucket Algorithm" - ], - "details": [ - "Calculate elapsed tokens upon request arrival rather than background ticker." - ] - }, - "solutionExplanation": "Lazy token bucket implementation.", - "classDiagramMermaid": "classDiagram\n class RateLimiter", - "starterFiles": { - "cpp": [ - { - "id": "src-rl-folder", - "name": "src", - "path": "src", - "isDirectory": true, - "children": [ - { - "id": "main-rl-java", - "name": "Main.java", - "path": "src/Main.java", - "content": "import controller.RateLimiterController;\n\npublic class Main {\n public static void main(String[] args) {\n System.out.println(\"=== Initializing Distributed Rate Limiter ===\");\n RateLimiterController limiter = new RateLimiterController();\n limiter.setTierLimit(\"FREE\", 5, 60);\n\n for (int i = 1; i \u003c= 6; i++) {\n boolean allowed = limiter.isAllowed(\"192.168.1.1\", \"FREE\");\n System.out.println(\"Request #\" + i + \" -\u003e Allowed: \" + allowed);\n }\n }\n}", - "isDirectory": false, - "language": "java" - }, - { - "id": "controller-rl-folder", - "name": "controller", - "path": "src/controller", - "isDirectory": true, - "children": [ - { - "id": "ratelimitercontroller-java", - "name": "RateLimiterController.java", - "path": "src/controller/RateLimiterController.java", - "content": "package controller;\n\nimport java.util.Map;\nimport java.util.concurrent.ConcurrentHashMap;\n\npublic class RateLimiterController {\n private static class Bucket {\n int capacity;\n double tokens;\n long lastRefill;\n double ratePerMs;\n\n Bucket(int cap, int sec) {\n this.capacity = cap;\n this.tokens = cap;\n this.lastRefill = System.currentTimeMillis();\n this.ratePerMs = (double) cap / (sec * 1000.0);\n }\n\n synchronized boolean tryConsume() {\n long now = System.currentTimeMillis();\n tokens = Math.min(capacity, tokens + (now - lastRefill) * ratePerMs);\n lastRefill = now;\n if (tokens \u003e= 1.0) {\n tokens -= 1.0;\n return true;\n }\n return false;\n }\n }\n\n private final Map\u003cString, Bucket\u003e buckets = new ConcurrentHashMap\u003c\u003e();\n\n public void setTierLimit(String tier, int maxRequests, int windowSeconds) {}\n\n public boolean isAllowed(String clientId, String tier) {\n Bucket b = buckets.computeIfAbsent(clientId, k -\u003e new Bucket(5, 60));\n return b.tryConsume();\n }\n}", - "isDirectory": false, - "language": "java" - } - ] - } - ] - } - ], - "go": [ - { - "id": "src-rl-folder", - "name": "src", - "path": "src", - "isDirectory": true, - "children": [ - { - "id": "main-rl-java", - "name": "Main.java", - "path": "src/Main.java", - "content": "import controller.RateLimiterController;\n\npublic class Main {\n public static void main(String[] args) {\n System.out.println(\"=== Initializing Distributed Rate Limiter ===\");\n RateLimiterController limiter = new RateLimiterController();\n limiter.setTierLimit(\"FREE\", 5, 60);\n\n for (int i = 1; i \u003c= 6; i++) {\n boolean allowed = limiter.isAllowed(\"192.168.1.1\", \"FREE\");\n System.out.println(\"Request #\" + i + \" -\u003e Allowed: \" + allowed);\n }\n }\n}", - "isDirectory": false, - "language": "java" - }, - { - "id": "controller-rl-folder", - "name": "controller", - "path": "src/controller", - "isDirectory": true, - "children": [ - { - "id": "ratelimitercontroller-java", - "name": "RateLimiterController.java", - "path": "src/controller/RateLimiterController.java", - "content": "package controller;\n\nimport java.util.Map;\nimport java.util.concurrent.ConcurrentHashMap;\n\npublic class RateLimiterController {\n private static class Bucket {\n int capacity;\n double tokens;\n long lastRefill;\n double ratePerMs;\n\n Bucket(int cap, int sec) {\n this.capacity = cap;\n this.tokens = cap;\n this.lastRefill = System.currentTimeMillis();\n this.ratePerMs = (double) cap / (sec * 1000.0);\n }\n\n synchronized boolean tryConsume() {\n long now = System.currentTimeMillis();\n tokens = Math.min(capacity, tokens + (now - lastRefill) * ratePerMs);\n lastRefill = now;\n if (tokens \u003e= 1.0) {\n tokens -= 1.0;\n return true;\n }\n return false;\n }\n }\n\n private final Map\u003cString, Bucket\u003e buckets = new ConcurrentHashMap\u003c\u003e();\n\n public void setTierLimit(String tier, int maxRequests, int windowSeconds) {}\n\n public boolean isAllowed(String clientId, String tier) {\n Bucket b = buckets.computeIfAbsent(clientId, k -\u003e new Bucket(5, 60));\n return b.tryConsume();\n }\n}", - "isDirectory": false, - "language": "java" - } - ] - } - ] - } - ], - "java": [ - { - "id": "src-rl-folder", - "name": "src", - "path": "src", - "isDirectory": true, - "children": [ - { - "id": "main-rl-java", - "name": "Main.java", - "path": "src/Main.java", - "content": "import controller.RateLimiterController;\n\npublic class Main {\n public static void main(String[] args) {\n System.out.println(\"=== Initializing Distributed Rate Limiter ===\");\n RateLimiterController limiter = new RateLimiterController();\n limiter.setTierLimit(\"FREE\", 5, 60);\n\n for (int i = 1; i \u003c= 6; i++) {\n boolean allowed = limiter.isAllowed(\"192.168.1.1\", \"FREE\");\n System.out.println(\"Request #\" + i + \" -\u003e Allowed: \" + allowed);\n }\n }\n}", - "isDirectory": false, - "language": "java" - }, - { - "id": "controller-rl-folder", - "name": "controller", - "path": "src/controller", - "isDirectory": true, - "children": [ - { - "id": "ratelimitercontroller-java", - "name": "RateLimiterController.java", - "path": "src/controller/RateLimiterController.java", - "content": "package controller;\n\nimport java.util.Map;\nimport java.util.concurrent.ConcurrentHashMap;\n\npublic class RateLimiterController {\n private static class Bucket {\n int capacity;\n double tokens;\n long lastRefill;\n double ratePerMs;\n\n Bucket(int cap, int sec) {\n this.capacity = cap;\n this.tokens = cap;\n this.lastRefill = System.currentTimeMillis();\n this.ratePerMs = (double) cap / (sec * 1000.0);\n }\n\n synchronized boolean tryConsume() {\n long now = System.currentTimeMillis();\n tokens = Math.min(capacity, tokens + (now - lastRefill) * ratePerMs);\n lastRefill = now;\n if (tokens \u003e= 1.0) {\n tokens -= 1.0;\n return true;\n }\n return false;\n }\n }\n\n private final Map\u003cString, Bucket\u003e buckets = new ConcurrentHashMap\u003c\u003e();\n\n public void setTierLimit(String tier, int maxRequests, int windowSeconds) {}\n\n public boolean isAllowed(String clientId, String tier) {\n Bucket b = buckets.computeIfAbsent(clientId, k -\u003e new Bucket(5, 60));\n return b.tryConsume();\n }\n}", - "isDirectory": false, - "language": "java" - } - ] - } - ] - } - ], - "python": [ - { - "id": "src-rl-folder", - "name": "src", - "path": "src", - "isDirectory": true, - "children": [ - { - "id": "main-rl-java", - "name": "Main.java", - "path": "src/Main.java", - "content": "import controller.RateLimiterController;\n\npublic class Main {\n public static void main(String[] args) {\n System.out.println(\"=== Initializing Distributed Rate Limiter ===\");\n RateLimiterController limiter = new RateLimiterController();\n limiter.setTierLimit(\"FREE\", 5, 60);\n\n for (int i = 1; i \u003c= 6; i++) {\n boolean allowed = limiter.isAllowed(\"192.168.1.1\", \"FREE\");\n System.out.println(\"Request #\" + i + \" -\u003e Allowed: \" + allowed);\n }\n }\n}", - "isDirectory": false, - "language": "java" - }, - { - "id": "controller-rl-folder", - "name": "controller", - "path": "src/controller", - "isDirectory": true, - "children": [ - { - "id": "ratelimitercontroller-java", - "name": "RateLimiterController.java", - "path": "src/controller/RateLimiterController.java", - "content": "package controller;\n\nimport java.util.Map;\nimport java.util.concurrent.ConcurrentHashMap;\n\npublic class RateLimiterController {\n private static class Bucket {\n int capacity;\n double tokens;\n long lastRefill;\n double ratePerMs;\n\n Bucket(int cap, int sec) {\n this.capacity = cap;\n this.tokens = cap;\n this.lastRefill = System.currentTimeMillis();\n this.ratePerMs = (double) cap / (sec * 1000.0);\n }\n\n synchronized boolean tryConsume() {\n long now = System.currentTimeMillis();\n tokens = Math.min(capacity, tokens + (now - lastRefill) * ratePerMs);\n lastRefill = now;\n if (tokens \u003e= 1.0) {\n tokens -= 1.0;\n return true;\n }\n return false;\n }\n }\n\n private final Map\u003cString, Bucket\u003e buckets = new ConcurrentHashMap\u003c\u003e();\n\n public void setTierLimit(String tier, int maxRequests, int windowSeconds) {}\n\n public boolean isAllowed(String clientId, String tier) {\n Bucket b = buckets.computeIfAbsent(clientId, k -\u003e new Bucket(5, 60));\n return b.tryConsume();\n }\n}", - "isDirectory": false, - "language": "java" - } - ] - } - ] - } - ], - "typescript": [ - { - "id": "src-rl-folder", - "name": "src", - "path": "src", - "isDirectory": true, - "children": [ - { - "id": "main-rl-java", - "name": "Main.java", - "path": "src/Main.java", - "content": "import controller.RateLimiterController;\n\npublic class Main {\n public static void main(String[] args) {\n System.out.println(\"=== Initializing Distributed Rate Limiter ===\");\n RateLimiterController limiter = new RateLimiterController();\n limiter.setTierLimit(\"FREE\", 5, 60);\n\n for (int i = 1; i \u003c= 6; i++) {\n boolean allowed = limiter.isAllowed(\"192.168.1.1\", \"FREE\");\n System.out.println(\"Request #\" + i + \" -\u003e Allowed: \" + allowed);\n }\n }\n}", - "isDirectory": false, - "language": "java" - }, - { - "id": "controller-rl-folder", - "name": "controller", - "path": "src/controller", - "isDirectory": true, - "children": [ - { - "id": "ratelimitercontroller-java", - "name": "RateLimiterController.java", - "path": "src/controller/RateLimiterController.java", - "content": "package controller;\n\nimport java.util.Map;\nimport java.util.concurrent.ConcurrentHashMap;\n\npublic class RateLimiterController {\n private static class Bucket {\n int capacity;\n double tokens;\n long lastRefill;\n double ratePerMs;\n\n Bucket(int cap, int sec) {\n this.capacity = cap;\n this.tokens = cap;\n this.lastRefill = System.currentTimeMillis();\n this.ratePerMs = (double) cap / (sec * 1000.0);\n }\n\n synchronized boolean tryConsume() {\n long now = System.currentTimeMillis();\n tokens = Math.min(capacity, tokens + (now - lastRefill) * ratePerMs);\n lastRefill = now;\n if (tokens \u003e= 1.0) {\n tokens -= 1.0;\n return true;\n }\n return false;\n }\n }\n\n private final Map\u003cString, Bucket\u003e buckets = new ConcurrentHashMap\u003c\u003e();\n\n public void setTierLimit(String tier, int maxRequests, int windowSeconds) {}\n\n public boolean isAllowed(String clientId, String tier) {\n Bucket b = buckets.computeIfAbsent(clientId, k -\u003e new Bucket(5, 60));\n return b.tryConsume();\n }\n}", - "isDirectory": false, - "language": "java" - } - ] - } - ] - } - ] - }, - "testCases": [ - { - "id": "tc-rl-1", - "name": "Normal Requests Allowed", - "description": "Hits isAllowed within quota.", - "category": "functionality", - "visibility": "public", - "apiMethod": "isAllowed", - "input": "isAllowed(\"user-1\", \"FREE\")", - "expectedOutput": "true", - "status": "idle", - "timeoutMs": 3000, - "concurrentWorkers": 1 - }, - { - "id": "tc-rl-2", - "name": "Concurrent Token Consumption", - "description": "Many parallel isAllowed calls never overspend the bucket.", - "category": "concurrency", - "visibility": "hidden", - "apiMethod": "isAllowed", - "input": "parallel isAllowed(\"user-1\", \"FREE\") x 100", - "expectedOutput": "Allowed count \u003c= configured capacity", - "status": "idle", - "timeoutMs": 5000, - "concurrentWorkers": 16 - } - ], - "createdAt": "2026-08-07T00:55:30.607398+05:30", - "updatedAt": "2026-08-07T00:55:30.607398+05:30" - }, - { - "id": "lru-cache", - "title": "LRU \u0026 LFU Cache", - "subtitle": "Design a high-performance in-memory cache with O(1) Get and Put operations and eviction policies", - "difficulty": "Easy", - "estimatedTime": "45 mins", - "companies": [ - "Amazon", - "Google", - "Microsoft", - "Bloomberg" - ], - "description": "Design a high-performance, in-memory key-value cache with a fixed capacity that supports O(1) time complexity for both get and put operations.", - "functionalRequirements": [ - "get(key): Retrieve item in O(1) time.", - "put(key, value): Insert item and evict least recently used when full." - ], - "nonFunctionalRequirements": [ - "Strict O(1) time complexity.", - "Thread safe concurrent operations." - ], - "constraints": [ - "Positive integer capacity." - ], - "apis": [ - { - "name": "get", - "signature": "get(key: K): V", - "description": "Returns value." - }, - { - "name": "put", - "signature": "put(key: K, value: V): void", - "description": "Stores key-value." - } - ], - "evaluationCriteria": [ - "Doubly Linked List + HashMap combination" - ], - "tips": { - "title": "LRU Cache Structure", - "patterns": [ - "Doubly Linked List + HashMap" - ], - "details": [ - "Head = Most Recent, Tail = Least Recent." - ] - }, - "solutionExplanation": "LinkedHashMap / DoublyLinkedList + HashMap.", - "classDiagramMermaid": "classDiagram\n class LRUCache", - "starterFiles": { - "cpp": [ - { - "id": "src-lru-folder", - "name": "src", - "path": "src", - "isDirectory": true, - "children": [ - { - "id": "main-lru-java", - "name": "Main.java", - "path": "src/Main.java", - "content": "import controller.LRUCache;\n\npublic class Main {\n public static void main(String[] args) {\n System.out.println(\"=== Initializing LRU Cache (Capacity = 3) ===\");\n LRUCache\u003cString, Integer\u003e cache = new LRUCache\u003c\u003e(3);\n cache.put(\"u1\", 100);\n cache.put(\"u2\", 200);\n cache.put(\"u3\", 300);\n System.out.println(\"Get u1: \" + cache.get(\"u1\"));\n cache.put(\"u4\", 400); // evicts u2\n System.out.println(\"Get u2 (evicted): \" + cache.get(\"u2\"));\n System.out.println(\"Get u3: \" + cache.get(\"u3\"));\n }\n}", - "isDirectory": false, - "language": "java" - }, - { - "id": "controller-lru-folder", - "name": "controller", - "path": "src/controller", - "isDirectory": true, - "children": [ - { - "id": "lrucache-java", - "name": "LRUCache.java", - "path": "src/controller/LRUCache.java", - "content": "package controller;\n\nimport java.util.LinkedHashMap;\nimport java.util.Map;\n\npublic class LRUCache\u003cK, V\u003e {\n private final int capacity;\n private final Map\u003cK, V\u003e map;\n\n public LRUCache(int capacity) {\n this.capacity = capacity;\n this.map = new LinkedHashMap\u003c\u003e(capacity, 0.75f, true) {\n @Override\n protected boolean removeEldestEntry(Map.Entry\u003cK, V\u003e eldest) {\n return size() \u003e capacity;\n }\n };\n }\n\n public synchronized V get(K key) { return map.get(key); }\n public synchronized void put(K key, V value) { map.put(key, value); }\n}", - "isDirectory": false, - "language": "java" - } - ] - } - ] - } - ], - "go": [ - { - "id": "src-lru-folder", - "name": "src", - "path": "src", - "isDirectory": true, - "children": [ - { - "id": "main-lru-java", - "name": "Main.java", - "path": "src/Main.java", - "content": "import controller.LRUCache;\n\npublic class Main {\n public static void main(String[] args) {\n System.out.println(\"=== Initializing LRU Cache (Capacity = 3) ===\");\n LRUCache\u003cString, Integer\u003e cache = new LRUCache\u003c\u003e(3);\n cache.put(\"u1\", 100);\n cache.put(\"u2\", 200);\n cache.put(\"u3\", 300);\n System.out.println(\"Get u1: \" + cache.get(\"u1\"));\n cache.put(\"u4\", 400); // evicts u2\n System.out.println(\"Get u2 (evicted): \" + cache.get(\"u2\"));\n System.out.println(\"Get u3: \" + cache.get(\"u3\"));\n }\n}", - "isDirectory": false, - "language": "java" - }, - { - "id": "controller-lru-folder", - "name": "controller", - "path": "src/controller", - "isDirectory": true, - "children": [ - { - "id": "lrucache-java", - "name": "LRUCache.java", - "path": "src/controller/LRUCache.java", - "content": "package controller;\n\nimport java.util.LinkedHashMap;\nimport java.util.Map;\n\npublic class LRUCache\u003cK, V\u003e {\n private final int capacity;\n private final Map\u003cK, V\u003e map;\n\n public LRUCache(int capacity) {\n this.capacity = capacity;\n this.map = new LinkedHashMap\u003c\u003e(capacity, 0.75f, true) {\n @Override\n protected boolean removeEldestEntry(Map.Entry\u003cK, V\u003e eldest) {\n return size() \u003e capacity;\n }\n };\n }\n\n public synchronized V get(K key) { return map.get(key); }\n public synchronized void put(K key, V value) { map.put(key, value); }\n}", - "isDirectory": false, - "language": "java" - } - ] - } - ] - } - ], - "java": [ - { - "id": "src-lru-folder", - "name": "src", - "path": "src", - "isDirectory": true, - "children": [ - { - "id": "main-lru-java", - "name": "Main.java", - "path": "src/Main.java", - "content": "import controller.LRUCache;\n\npublic class Main {\n public static void main(String[] args) {\n System.out.println(\"=== Initializing LRU Cache (Capacity = 3) ===\");\n LRUCache\u003cString, Integer\u003e cache = new LRUCache\u003c\u003e(3);\n cache.put(\"u1\", 100);\n cache.put(\"u2\", 200);\n cache.put(\"u3\", 300);\n System.out.println(\"Get u1: \" + cache.get(\"u1\"));\n cache.put(\"u4\", 400); // evicts u2\n System.out.println(\"Get u2 (evicted): \" + cache.get(\"u2\"));\n System.out.println(\"Get u3: \" + cache.get(\"u3\"));\n }\n}", - "isDirectory": false, - "language": "java" - }, - { - "id": "controller-lru-folder", - "name": "controller", - "path": "src/controller", - "isDirectory": true, - "children": [ - { - "id": "lrucache-java", - "name": "LRUCache.java", - "path": "src/controller/LRUCache.java", - "content": "package controller;\n\nimport java.util.LinkedHashMap;\nimport java.util.Map;\n\npublic class LRUCache\u003cK, V\u003e {\n private final int capacity;\n private final Map\u003cK, V\u003e map;\n\n public LRUCache(int capacity) {\n this.capacity = capacity;\n this.map = new LinkedHashMap\u003c\u003e(capacity, 0.75f, true) {\n @Override\n protected boolean removeEldestEntry(Map.Entry\u003cK, V\u003e eldest) {\n return size() \u003e capacity;\n }\n };\n }\n\n public synchronized V get(K key) { return map.get(key); }\n public synchronized void put(K key, V value) { map.put(key, value); }\n}", - "isDirectory": false, - "language": "java" - } - ] - } - ] - } - ], - "python": [ - { - "id": "src-lru-folder", - "name": "src", - "path": "src", - "isDirectory": true, - "children": [ - { - "id": "main-lru-java", - "name": "Main.java", - "path": "src/Main.java", - "content": "import controller.LRUCache;\n\npublic class Main {\n public static void main(String[] args) {\n System.out.println(\"=== Initializing LRU Cache (Capacity = 3) ===\");\n LRUCache\u003cString, Integer\u003e cache = new LRUCache\u003c\u003e(3);\n cache.put(\"u1\", 100);\n cache.put(\"u2\", 200);\n cache.put(\"u3\", 300);\n System.out.println(\"Get u1: \" + cache.get(\"u1\"));\n cache.put(\"u4\", 400); // evicts u2\n System.out.println(\"Get u2 (evicted): \" + cache.get(\"u2\"));\n System.out.println(\"Get u3: \" + cache.get(\"u3\"));\n }\n}", - "isDirectory": false, - "language": "java" - }, - { - "id": "controller-lru-folder", - "name": "controller", - "path": "src/controller", - "isDirectory": true, - "children": [ - { - "id": "lrucache-java", - "name": "LRUCache.java", - "path": "src/controller/LRUCache.java", - "content": "package controller;\n\nimport java.util.LinkedHashMap;\nimport java.util.Map;\n\npublic class LRUCache\u003cK, V\u003e {\n private final int capacity;\n private final Map\u003cK, V\u003e map;\n\n public LRUCache(int capacity) {\n this.capacity = capacity;\n this.map = new LinkedHashMap\u003c\u003e(capacity, 0.75f, true) {\n @Override\n protected boolean removeEldestEntry(Map.Entry\u003cK, V\u003e eldest) {\n return size() \u003e capacity;\n }\n };\n }\n\n public synchronized V get(K key) { return map.get(key); }\n public synchronized void put(K key, V value) { map.put(key, value); }\n}", - "isDirectory": false, - "language": "java" - } - ] - } - ] - } - ], - "typescript": [ - { - "id": "src-lru-folder", - "name": "src", - "path": "src", - "isDirectory": true, - "children": [ - { - "id": "main-lru-java", - "name": "Main.java", - "path": "src/Main.java", - "content": "import controller.LRUCache;\n\npublic class Main {\n public static void main(String[] args) {\n System.out.println(\"=== Initializing LRU Cache (Capacity = 3) ===\");\n LRUCache\u003cString, Integer\u003e cache = new LRUCache\u003c\u003e(3);\n cache.put(\"u1\", 100);\n cache.put(\"u2\", 200);\n cache.put(\"u3\", 300);\n System.out.println(\"Get u1: \" + cache.get(\"u1\"));\n cache.put(\"u4\", 400); // evicts u2\n System.out.println(\"Get u2 (evicted): \" + cache.get(\"u2\"));\n System.out.println(\"Get u3: \" + cache.get(\"u3\"));\n }\n}", - "isDirectory": false, - "language": "java" - }, - { - "id": "controller-lru-folder", - "name": "controller", - "path": "src/controller", - "isDirectory": true, - "children": [ - { - "id": "lrucache-java", - "name": "LRUCache.java", - "path": "src/controller/LRUCache.java", - "content": "package controller;\n\nimport java.util.LinkedHashMap;\nimport java.util.Map;\n\npublic class LRUCache\u003cK, V\u003e {\n private final int capacity;\n private final Map\u003cK, V\u003e map;\n\n public LRUCache(int capacity) {\n this.capacity = capacity;\n this.map = new LinkedHashMap\u003c\u003e(capacity, 0.75f, true) {\n @Override\n protected boolean removeEldestEntry(Map.Entry\u003cK, V\u003e eldest) {\n return size() \u003e capacity;\n }\n };\n }\n\n public synchronized V get(K key) { return map.get(key); }\n public synchronized void put(K key, V value) { map.put(key, value); }\n}", - "isDirectory": false, - "language": "java" - } - ] - } - ] - } - ] - }, - "testCases": [ - { - "id": "tc-lru-1", - "name": "Get and Put", - "description": "Hits put then get APIs for a single key.", - "category": "functionality", - "visibility": "public", - "apiMethod": "get", - "input": "put(\"a\", 100); get(\"a\")", - "expectedOutput": "100", - "status": "idle", - "timeoutMs": 3000, - "concurrentWorkers": 1 - }, - { - "id": "tc-lru-2", - "name": "Concurrent Put/Get", - "description": "Parallel put/get must remain consistent under contention.", - "category": "concurrency", - "visibility": "hidden", - "apiMethod": "put", - "input": "parallel put/get across shared keys", - "expectedOutput": "No crashes; reads observe last successful writes", - "status": "idle", - "timeoutMs": 5000, - "concurrentWorkers": 8 - } - ], - "createdAt": "2026-08-07T00:55:30.607398+05:30", - "updatedAt": "2026-08-07T00:55:30.607398+05:30" - } -] \ No newline at end of file diff --git a/interviewhub-backend-go/test/refresh_tokens.json b/interviewhub-backend-go/test/refresh_tokens.json deleted file mode 100644 index a85e2a56..00000000 --- a/interviewhub-backend-go/test/refresh_tokens.json +++ /dev/null @@ -1,38 +0,0 @@ -[ - { - "id": "rt_5e24f043-c1ab-4adf-a83d-5a6df365c273", - "userId": "usr_6263144c", - "familyId": "fam_7b06ba48-28b6-4d38-93d5-a92d7fb3ccae", - "tokenHash": "IlJRoRBQHQC-AlujJuICjF59XntTeY9-tahe3N2_m1U", - "expiresAt": "2026-08-07T01:55:30.667965+05:30", - "createdAt": "2026-08-07T00:55:30.667965+05:30", - "revokedAt": "2026-08-07T00:55:30.668067+05:30" - }, - { - "id": "rt_2cb87ccc-b54c-44a2-88b0-033be8bfbbf7", - "userId": "usr_6263144c", - "familyId": "fam_7b06ba48-28b6-4d38-93d5-a92d7fb3ccae", - "tokenHash": "PQnrBZlqeHvXDEDSEAFVUUS-xp3niSC85x3YFvuHZ3w", - "expiresAt": "2026-08-07T01:55:30.668139+05:30", - "createdAt": "2026-08-07T00:55:30.668139+05:30", - "revokedAt": "2026-08-07T00:55:30.668197+05:30" - }, - { - "id": "rt_7f23b003-4d3f-4b35-99b1-ab08f3312e10", - "userId": "usr_36679b08", - "familyId": "fam_c024f4e0-8d15-4e91-8026-b027bc74752a", - "tokenHash": "BiV3OoeQp_ehChKwcuYJgHFX5j3Ca8-N6JlbSTJL5CQ", - "expiresAt": "2026-08-07T01:58:06.513844+05:30", - "createdAt": "2026-08-07T00:58:06.513844+05:30", - "revokedAt": "2026-08-07T00:58:06.513941+05:30" - }, - { - "id": "rt_35f0266e-2f1a-4cc8-b706-99882b020c1b", - "userId": "usr_36679b08", - "familyId": "fam_c024f4e0-8d15-4e91-8026-b027bc74752a", - "tokenHash": "rB7mT2DE_WdZQEQxM9Zo0SB6chpjYB9vM3D3eXvsMOM", - "expiresAt": "2026-08-07T01:58:06.514075+05:30", - "createdAt": "2026-08-07T00:58:06.514075+05:30", - "revokedAt": "2026-08-07T00:58:06.514153+05:30" - } -] \ No newline at end of file diff --git a/interviewhub-backend-go/test/users.json b/interviewhub-backend-go/test/users.json deleted file mode 100644 index 4c5652a5..00000000 --- a/interviewhub-backend-go/test/users.json +++ /dev/null @@ -1,32 +0,0 @@ -[ - { - "id": "usr_cand_01", - "name": "Alex Rivera", - "email": "candidate@google.com", - "role": "candidate", - "createdAt": "2026-08-07T00:55:30.606939+05:30" - }, - { - "id": "usr_6263144c", - "name": "Candidate", - "email": "candidate-20260807005530.608454000@example.com", - "role": "candidate", - "authProvider": "local", - "createdAt": "2026-08-07T00:55:30.66773+05:30" - }, - { - "id": "usr_36679b08", - "name": "Candidate", - "email": "candidate-20260807005806.433096000@example.com", - "role": "candidate", - "authProvider": "local", - "createdAt": "2026-08-07T00:58:06.513561+05:30" - }, - { - "id": "usr_admin_01", - "name": "System Administrator", - "email": "admin@interviewhub.io", - "role": "admin", - "createdAt": "2026-08-07T00:55:30.606939+05:30" - } -] \ No newline at end of file diff --git a/interviewhub-lld/.dockerignore b/interviewhub-lld/.dockerignore new file mode 100644 index 00000000..b529b90b --- /dev/null +++ b/interviewhub-lld/.dockerignore @@ -0,0 +1,11 @@ +.git +.github +.env +.env.* +node_modules +dist +coverage +*.log +verify_*.mjs +verify_*.cjs +README.md diff --git a/interviewhub-lld/Dockerfile b/interviewhub-lld/Dockerfile index 0d4bf1f1..20dc846a 100644 --- a/interviewhub-lld/Dockerfile +++ b/interviewhub-lld/Dockerfile @@ -1,11 +1,29 @@ -FROM node:20-alpine AS build +# syntax=docker/dockerfile:1.7 + +FROM node:22-alpine AS build +ENV PUPPETEER_SKIP_DOWNLOAD=true WORKDIR /app -COPY package.json package-lock.json* ./ -RUN npm ci --silent + +COPY package.json package-lock.json ./ +RUN --mount=type=cache,target=/root/.npm \ + npm ci --no-audit --no-fund + COPY . . +ARG VITE_API_BASE_URL=http://localhost:5050/api +ENV VITE_API_BASE_URL=$VITE_API_BASE_URL RUN npm run build FROM nginx:stable-alpine -COPY --from=build /app/dist /usr/share/nginx/html -EXPOSE 80 -CMD ["/bin/sh", "-c", "nginx -g 'daemon off;'" ] +RUN rm -f /etc/nginx/conf.d/default.conf \ + && mkdir -p /var/cache/nginx /var/run/nginx \ + && chown -R nginx:nginx /var/cache/nginx /var/run/nginx /etc/nginx + +COPY nginx.conf /etc/nginx/nginx.conf +COPY --from=build --chown=nginx:nginx /app/dist /usr/share/nginx/html + +USER nginx +EXPOSE 8080 +HEALTHCHECK --interval=10s --timeout=3s --start-period=5s --retries=3 \ + CMD wget -q -T 2 -O /dev/null http://127.0.0.1:8080/ || exit 1 + +ENTRYPOINT ["nginx", "-g", "daemon off;"] diff --git a/interviewhub-lld/README.md b/interviewhub-lld/README.md index b7aaf3b3..3edaaef2 100644 --- a/interviewhub-lld/README.md +++ b/interviewhub-lld/README.md @@ -1,40 +1,81 @@ -# InterviewHub LLD Frontend +# JudgeMyCode frontend -This is the TypeScript + React frontend for the InterviewHub LLD app. It is built with Vite and connects to the Go backend API. +The frontend is a React 19 + TypeScript + Vite application using Monaco Editor. Use the repository-root command for normal development: -Quick start (development): +```bash +./run-local.sh +``` -1. Install dependencies +For frontend-only work, use Node.js 22.12+: ```bash -cd interviewhub-lld -npm install +npm ci +test -f .env.local || cp .env.example .env.local +npm run dev ``` -2. Point the frontend to the backend API (default is `http://localhost:5050/api`). You can set this with an env file: +Vite listens on `http://127.0.0.1:5173` in the root workflow. `VITE_API_BASE_URL` defaults locally to `http://127.0.0.1:5050/api`. -Create `.env` or `.env.local` with: +## Validate and build -``` -VITE_API_BASE_URL=http://localhost:5050/api +```bash +npm run typecheck +npm run test:session-boundaries +npm run build +npm run preview ``` -3. Run dev server +With the local stack running, exercise the real login and workspace route: ```bash -npm run dev +npm run test:e2e ``` -Build for production +`APP_URL`, `E2E_EMAIL`, `E2E_PASSWORD`, `E2E_SCREENSHOT`, and +`PUPPETEER_EXECUTABLE_PATH` are optional overrides. The defaults target the +loopback stack and its opt-in local candidate seed; never reuse those demo +credentials outside development. + +The production container uses Node 22 to build, then serves the SPA as a non-root nginx process on port `8080`. Its nginx configuration falls back to `index.html` for client-side routes and applies immutable caching only to hashed assets. + +The production bundle lazy-loads the Monaco editor and separates React, icons, +and confetti into stable vendor chunks so non-editor routes do not pay the full +IDE startup cost. ```bash -npm run build -npm run preview +docker build --build-arg VITE_API_BASE_URL=https://api.example.com/api -t judgemycode-web . +docker run --rm -p 8080:8080 judgemycode-web ``` -Notes +`VITE_*` variables are compiled into browser assets and must never contain secrets. + +## Session security + +The client stores one atomic session document and treats `/auth/me` as the sole +identity/role authority; login response DTOs never write user UI state. Every +login gets a principal-bound epoch that survives +refresh rotation but changes on a fresh sign-in. A delayed `401` can only retry +inside that exact lineage, so an account-A request cannot be replayed with an +account-B token; the bearer is captured with the request before headers are +constructed. Authentication failures clear only the exact snapshot they +validated, so a stale response cannot erase a newer account. Every login, +refresh, clear, and logout mutation uses one +browser-wide Web Lock, preventing an old operation from overwriting a newer +login. Browsers without Web Locks fail closed; an expiring page-managed lease +is not safe for single-use credential rotation. Failed +network logouts use independent per-credential queue records, so simultaneous +tabs cannot overwrite one another. The bounded, deduplicated queue is retried +at startup, on `online`, on cross-tab queue changes, and around later account +transitions without restoring the visible session. Network queue draining uses +a separate lock, so old revocations do not block a new authentication. If durable queueing fails, +the current session remains visible and the UI reports that sign-out was not +safe instead of pretending it succeeded. -- The app stores auth tokens in `localStorage` under `interviewhub_token` and `interviewhub_refresh_token`. -- The frontend already contains an API client at `src/services/apiClient.ts` that calls the backend endpoints under `/api`. +Workspace recovery is also tab-aware. Each document writes a unique local +journal, clean superseded journals are pruned, and server saves use optimistic +revisions with explicit conflict controls. -If you need a Docker image, see the included `Dockerfile`. +Browser token storage remains a development limitation: a production +deployment should move refresh credentials to `Secure`, `HttpOnly`, `SameSite` +cookies, keep short-lived access tokens in memory, and enforce a restrictive +Content Security Policy. diff --git a/interviewhub-lld/nginx.conf b/interviewhub-lld/nginx.conf new file mode 100644 index 00000000..6a5de3ef --- /dev/null +++ b/interviewhub-lld/nginx.conf @@ -0,0 +1,55 @@ +pid /tmp/nginx.pid; +worker_processes auto; +error_log /dev/stderr warn; + +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + access_log /dev/stdout; + server_tokens off; + sendfile on; + + client_body_temp_path /tmp/client_temp; + proxy_temp_path /tmp/proxy_temp; + fastcgi_temp_path /tmp/fastcgi_temp; + uwsgi_temp_path /tmp/uwsgi_temp; + scgi_temp_path /tmp/scgi_temp; + + server { + listen 8080; + listen [::]:8080; + root /usr/share/nginx/html; + index index.html; + + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; + add_header X-Frame-Options "DENY" always; + + location = /index.html { + add_header Cache-Control "no-store"; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; + add_header X-Frame-Options "DENY" always; + } + + location /assets/ { + try_files $uri =404; + expires 1y; + add_header Cache-Control "public, immutable"; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; + add_header X-Frame-Options "DENY" always; + } + + location / { + try_files $uri $uri/ /index.html; + } + } +} diff --git a/interviewhub-lld/package.json b/interviewhub-lld/package.json index ff08dc27..bd614301 100644 --- a/interviewhub-lld/package.json +++ b/interviewhub-lld/package.json @@ -5,8 +5,11 @@ "type": "module", "scripts": { "dev": "vite", - "build": "vite build", - "preview": "vite preview" + "typecheck": "tsc --noEmit", + "build": "npm run typecheck && vite build", + "preview": "vite preview", + "test:session-boundaries": "node --experimental-strip-types ./scripts/verify-session-boundaries.mjs", + "test:e2e": "node ./scripts/verify-e2e.mjs" }, "dependencies": { "@monaco-editor/react": "^4.7.0", diff --git a/interviewhub-lld/scripts/verify-e2e.mjs b/interviewhub-lld/scripts/verify-e2e.mjs new file mode 100644 index 00000000..5d1cc3b2 --- /dev/null +++ b/interviewhub-lld/scripts/verify-e2e.mjs @@ -0,0 +1,199 @@ +import puppeteer from 'puppeteer'; + +const appUrl = new URL(process.env.APP_URL || 'http://127.0.0.1:5173'); +const email = process.env.E2E_EMAIL || 'candidate@google.com'; +const password = process.env.E2E_PASSWORD || 'pass123'; +const racingEmail = process.env.E2E_RACING_EMAIL || 'admin@interviewhub.io'; +const racingPassword = process.env.E2E_RACING_PASSWORD || 'admin123'; +const screenshotPath = process.env.E2E_SCREENSHOT; + +const launchOptions = { + headless: true, + args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage'] +}; +if (process.env.PUPPETEER_EXECUTABLE_PATH) { + launchOptions.executablePath = process.env.PUPPETEER_EXECUTABLE_PATH; +} + +const browser = await puppeteer.launch(launchOptions); +const page = await browser.newPage(); +const pageErrors = []; + +const collectErrors = (target, label) => { + target.on('pageerror', (error) => pageErrors.push(`${label}: ${error.message}`)); + target.on('console', (message) => { + if (message.type() === 'error') pageErrors.push(`${label}: ${message.text()}`); + }); +}; +collectErrors(page, 'primary tab'); + +try { + await page.setViewport({ width: 1440, height: 900 }); + await page.goto(new URL('/login', appUrl).href, { waitUntil: 'domcontentloaded', timeout: 30_000 }); + await page.evaluate(() => window.localStorage.clear()); + await page.reload({ waitUntil: 'domcontentloaded', timeout: 30_000 }); + + await page.waitForSelector('.auth-form input[type="email"]', { timeout: 15_000 }); + await page.type('.auth-form input[type="email"]', email); + await page.type('.auth-form input[type="password"]', password); + await page.click('.auth-form button[type="submit"]'); + await page.waitForFunction(() => window.location.pathname === '/home', { timeout: 20_000 }); + + const sessionPersisted = await page.evaluate(() => { + try { + const session = JSON.parse(window.localStorage.getItem('judgemycode_session_v2') || 'null'); + return Boolean(session?.accessToken && session?.refreshToken); + } catch { + return false; + } + }); + if (!sessionPersisted) throw new Error('login completed without persisting the local development session'); + + const secondPage = await browser.newPage(); + collectErrors(secondPage, 'second tab'); + await secondPage.goto(new URL('/home', appUrl).href, { waitUntil: 'domcontentloaded', timeout: 30_000 }); + + // Both tabs deliberately observe the same generation before rotating. The + // browser-wide lock must let only one request consume it; the waiter adopts + // the replacement rather than triggering family replay detection. + const refreshIn = (target) => target.evaluate(async () => { + const { httpClient } = await import('/src/shared/api/httpClient.ts'); + const result = await httpClient.refreshSession(); + return { success: result.success, status: result.status, error: result.error || '' }; + }); + const concurrentRefreshes = await Promise.all([refreshIn(page), refreshIn(secondPage)]); + if (concurrentRefreshes.some((result) => !result.success)) { + throw new Error(`cross-tab refresh failed: ${JSON.stringify(concurrentRefreshes)}`); + } + + // Hold one real refresh request after it acquires the browser session lock, + // then begin a fresh login in the other page. The login mutation must wait + // and commit last; an older refresh may never overwrite that new epoch. + let heldRefreshRequest; + let signalHeldRefresh; + const heldRefreshSeen = new Promise((resolve) => { signalHeldRefresh = resolve; }); + const interceptRefresh = (request) => { + if (!heldRefreshRequest && request.method() === 'POST' && request.url().endsWith('/api/auth/refresh')) { + heldRefreshRequest = request; + signalHeldRefresh(); + return; + } + void request.continue(); + }; + await page.setRequestInterception(true); + page.on('request', interceptRefresh); + + const lockedRefresh = refreshIn(page); + await Promise.race([ + heldRefreshSeen, + new Promise((_, reject) => setTimeout(() => reject(new Error('did not intercept the locked refresh')), 10_000)) + ]); + let signalLoginRequest; + const loginRequestSeen = new Promise((resolve) => { signalLoginRequest = resolve; }); + const observeLogin = (request) => { + if (request.method() === 'POST' && request.url().endsWith('/api/auth/login')) signalLoginRequest(); + }; + secondPage.on('request', observeLogin); + const relogin = secondPage.evaluate(async ({ loginEmail, loginPassword }) => { + const { authApi } = await import('/src/features/auth/api.ts'); + const result = await authApi.login(loginEmail, loginPassword); + return { + success: result.success, + status: result.status, + error: result.error || '', + refreshToken: result.data?.refreshToken || '' + }; + }, { loginEmail: racingEmail, loginPassword: racingPassword }); + + await Promise.race([ + loginRequestSeen, + new Promise((_, reject) => setTimeout(() => reject(new Error('the racing login request did not start')), 10_000)) + ]); + await heldRefreshRequest.continue(); + const [lockedRefreshResult, reloginResult] = await Promise.all([lockedRefresh, relogin]); + secondPage.off('request', observeLogin); + page.off('request', interceptRefresh); + await page.setRequestInterception(false); + if (!lockedRefreshResult.success || !reloginResult.success) { + throw new Error(`refresh/login serialization failed: ${JSON.stringify({ lockedRefreshResult, reloginResult })}`); + } + const finalRefreshToken = await page.evaluate(() => { + const session = JSON.parse(window.localStorage.getItem('judgemycode_session_v2') || 'null'); + return session?.refreshToken || ''; + }); + if (!reloginResult.refreshToken || finalRefreshToken !== reloginResult.refreshToken) { + throw new Error('the fresh login did not commit after the older locked refresh'); + } + + // A further rotation plus an authenticated read proves the family remained + // usable and neither tab replayed the original token. + const followupRefresh = await refreshIn(page); + if (!followupRefresh.success) throw new Error(`follow-up refresh failed: ${JSON.stringify(followupRefresh)}`); + const verifyMe = (target) => target.evaluate(async () => { + const { httpClient } = await import('/src/shared/api/httpClient.ts'); + const result = await httpClient.request('/auth/me'); + return { + success: result.success, + status: result.status, + error: result.error || '', + email: result.data?.email || '', + name: result.data?.name || '' + }; + }); + const verifiedTabs = await Promise.all([verifyMe(page), verifyMe(secondPage)]); + if (verifiedTabs.some((result) => !result.success)) { + throw new Error(`cross-tab session verification failed: ${JSON.stringify(verifiedTabs)}`); + } + if (verifiedTabs.some((result) => result.email !== racingEmail)) { + throw new Error(`tabs did not converge on the fresh login identity: ${JSON.stringify(verifiedTabs)}`); + } + await secondPage.goto(new URL('/home', appUrl).href, { waitUntil: 'domcontentloaded', timeout: 30_000 }); + await secondPage.waitForFunction( + (expectedName) => document.body.innerText.includes(expectedName), + { timeout: 20_000, polling: 100 }, + verifiedTabs[0].name + ); + + await page.goto(new URL('/workspace/parking-lot', appUrl).href, { + waitUntil: 'domcontentloaded', + timeout: 30_000 + }); + try { + await page.waitForFunction( + () => document.body.innerText.includes('Run Code') && document.body.innerText.includes('Parking'), + // The primary page is a background tab after the cross-tab refresh test. + // Timer polling avoids Chromium's background requestAnimationFrame throttle. + { timeout: 20_000, polling: 100 } + ); + } catch (error) { + const diagnostic = await page.evaluate(() => ({ + href: window.location.href, + readyState: document.readyState, + text: document.body?.innerText.slice(0, 2_000) || '', + hasSession: Boolean(window.localStorage.getItem('judgemycode_session_v2')) + })); + throw new Error( + `workspace did not settle: ${JSON.stringify(diagnostic)}; ${error instanceof Error ? error.message : String(error)}` + ); + } + + if (screenshotPath) { + await page.screenshot({ path: screenshotPath, fullPage: false }); + } + if (pageErrors.length > 0) { + throw new Error(`browser errors:\n${pageErrors.map((error) => `- ${error}`).join('\n')}`); + } + + process.stdout.write(`E2E smoke passed: ${appUrl.origin} login -> two-tab refresh -> refresh/login serialization -> workspace\n`); +} finally { + try { + await page.evaluate(async () => { + const { authApi } = await import('/src/features/auth/api.ts'); + await authApi.logout(); + }); + } catch { + // The primary failure remains authoritative; cleanup is best-effort when + // the page or backend disappeared during the scenario. + } + await browser.close(); +} diff --git a/interviewhub-lld/scripts/verify-session-boundaries.mjs b/interviewhub-lld/scripts/verify-session-boundaries.mjs new file mode 100644 index 00000000..54e9c32e --- /dev/null +++ b/interviewhub-lld/scripts/verify-session-boundaries.mjs @@ -0,0 +1,260 @@ +import assert from 'node:assert/strict'; + +class MemoryStorage { + #values = new Map(); + failPendingWrites = false; + failSessionWrites = false; + + get length() { + return this.#values.size; + } + + clear() { + this.#values.clear(); + } + + getItem(key) { + return this.#values.has(key) ? this.#values.get(key) : null; + } + + key(index) { + return [...this.#values.keys()][index] ?? null; + } + + removeItem(key) { + this.#values.delete(key); + } + + setItem(key, value) { + if (this.failPendingWrites && key.startsWith('judgemycode_pending_logout_item:')) { + throw new Error('simulated quota failure'); + } + if (this.failSessionWrites && key === 'judgemycode_session_v2') { + throw new Error('simulated session persistence failure'); + } + this.#values.set(key, String(value)); + } +} + +class MemoryLockManager { + #tail = Promise.resolve(); + + request(_name, options, callback) { + if (options?.signal?.aborted) return Promise.reject(new Error('lock request aborted')); + const result = this.#tail.then(callback, callback); + this.#tail = result.then(() => undefined, () => undefined); + return result; + } +} + +const localStorage = new MemoryStorage(); +const browserListeners = new Map(); +globalThis.window = { + localStorage, + setTimeout: globalThis.setTimeout.bind(globalThis), + clearTimeout: globalThis.clearTimeout.bind(globalThis), + addEventListener(type, listener) { + const listeners = browserListeners.get(type) || new Set(); + listeners.add(listener); + browserListeners.set(type, listeners); + }, + removeEventListener(type, listener) { + browserListeners.get(type)?.delete(listener); + } +}; +Object.defineProperty(globalThis, 'navigator', { + configurable: true, + value: { locks: new MemoryLockManager() } +}); + +const encode = (value) => Buffer.from(JSON.stringify(value)).toString('base64url'); +const accessToken = (subject, generation) => `${encode({ alg: 'HS256', typ: 'JWT' })}.${encode({ sub: subject, id: subject })}.signature-${subject}-${generation}`; + +const waitFor = async (predicate, message) => { + const deadline = Date.now() + 2_000; + while (!predicate()) { + if (Date.now() >= deadline) throw new Error(message); + await new Promise((resolve) => setTimeout(resolve, 5)); + } +}; + +const { tokenStore } = await import('../src/shared/api/tokenStore.ts'); +const { httpClient } = await import('../src/shared/api/httpClient.ts'); + +const a1 = accessToken('user-a', 1); +const a2 = accessToken('user-a', 2); +const b1 = accessToken('user-b', 1); + +await tokenStore.replaceSession(a1, 'refresh-a-1'); +const originalA = tokenStore.getSnapshot(); +assert.equal(originalA.principalId, 'user-a'); +assert.ok(originalA.sessionEpoch, 'a new login must receive an epoch'); +assert.equal(await tokenStore.rotateSession(a2, 'refresh-a-2', originalA), true); +const rotatedA = tokenStore.getSnapshot(); +assert.equal(rotatedA.sessionEpoch, originalA.sessionEpoch, 'refresh must preserve the login epoch'); + +await tokenStore.replaceSession(b1, 'refresh-b-1'); +const sessionB = tokenStore.getSnapshot(); +assert.equal(sessionB.principalId, 'user-b'); +assert.notEqual(sessionB.sessionEpoch, originalA.sessionEpoch, 'a fresh login must start a new epoch'); +assert.equal(await tokenStore.rotateSession(a2, 'refresh-a-3', originalA), false, 'stale refresh must not replace a newer login'); +assert.equal(tokenStore.getSnapshot().principalId, 'user-b'); + +await tokenStore.replaceSession(a1, 'refresh-a-stale-clear'); +const staleClearA = tokenStore.getSnapshot(); +await tokenStore.replaceSession(b1, 'refresh-b-survives-clear'); +assert.equal( + await tokenStore.clearIfUnchanged(staleClearA), + false, + 'a stale authentication failure must not clear a newer account' +); +assert.equal(tokenStore.getSnapshot().principalId, 'user-b'); + +await tokenStore.replaceSession(a1, 'refresh-persistence-a'); +localStorage.failSessionWrites = true; +await assert.rejects( + tokenStore.replaceSession(b1, 'refresh-persistence-b'), + /persist the browser session/, + 'a login must fail explicitly when its session cannot be persisted' +); +assert.equal(tokenStore.getSnapshot().principalId, 'user-a', 'failed persistence must retain the prior session'); +localStorage.failSessionWrites = false; + +localStorage.setItem('judgemycode_pending_logout', 'legacy-refresh-token'); +assert.deepEqual(tokenStore.getPendingLogoutTokens(), ['legacy-refresh-token'], 'legacy single-token logout state must migrate'); +await tokenStore.completeLogout('legacy-refresh-token'); + +await tokenStore.replaceSession(a1, 'refresh-crash-window'); +localStorage.setItem('judgemycode_pending_logout', JSON.stringify([{ + refreshToken: 'refresh-crash-window', + queuedAt: Date.now() +}])); +assert.equal( + tokenStore.getSnapshot().accessToken, + null, + 'a crash between queueing and clearing logout must still fail closed on restart' +); +await tokenStore.completeLogout('refresh-crash-window'); + +await tokenStore.replaceSession(a1, 'refresh-revoked-before-clear'); +localStorage.setItem('judgemycode_pending_logout', JSON.stringify([{ + refreshToken: 'refresh-revoked-before-clear', + queuedAt: Date.now() +}])); +await tokenStore.completeLogout('refresh-revoked-before-clear'); +assert.equal(tokenStore.getSnapshot().refreshToken, null, 'completing revocation must remove a matching persisted session'); +assert.deepEqual(tokenStore.getPendingLogoutTokens(), []); + +await tokenStore.replaceSession(a1, 'refresh-a-queue'); +await tokenStore.beginLogout(); +await tokenStore.replaceSession(b1, 'refresh-b-queue'); +await tokenStore.beginLogout(); +await tokenStore.replaceSession(b1, 'refresh-b-queue'); +await tokenStore.beginLogout(); +assert.deepEqual( + tokenStore.getPendingLogoutTokens().sort(), + ['refresh-a-queue', 'refresh-b-queue'], + 'logout revocations must be durable and deduplicated across account transitions' +); +await tokenStore.completeLogout('refresh-a-queue'); +await tokenStore.completeLogout('refresh-b-queue'); + +await tokenStore.replaceSession(a1, 'refresh-storage-failure'); +localStorage.failPendingWrites = true; +await assert.rejects( + tokenStore.beginLogout(), + /preserve this session/, + 'logout must remain authenticated when durable queue persistence fails' +); +assert.equal(tokenStore.getSnapshot().refreshToken, 'refresh-storage-failure'); +localStorage.failPendingWrites = false; +await tokenStore.beginLogout(); +await tokenStore.completeLogout('refresh-storage-failure'); + +const dispatches = []; +globalThis.fetch = async (_url, options) => { + dispatches.push({ + authorization: new Headers(options?.headers).get('Authorization'), + body: options?.body + }); + return new Response(JSON.stringify({ success: true, data: { ok: true } }), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }); +}; + +await tokenStore.replaceSession(b1, 'refresh-b-dispatch'); +const dispatchSessionB = tokenStore.getSnapshot(); +await tokenStore.replaceSession(a1, 'refresh-a-dispatch'); +const switchingHeaders = { + *[Symbol.iterator]() { + // Simulate another document completing its locked localStorage commit at + // the most adversarial point in this document's header construction. + localStorage.setItem('judgemycode_session_v2', JSON.stringify(dispatchSessionB)); + yield ['X-Dispatch-Test', 'account-switch-during-header-construction']; + } +}; +const dispatchResult = await httpClient.request('/dispatch-boundary', { + method: 'POST', + body: JSON.stringify({ owner: 'user-a' }), + headers: switchingHeaders, + retryUnauthorized: false +}); +assert.equal(dispatchResult.success, true); +assert.equal(dispatches[0].authorization, `Bearer ${a1}`, 'request dispatch must use the bearer captured with its body'); +assert.equal(dispatches[0].body, JSON.stringify({ owner: 'user-a' })); +assert.equal(tokenStore.getSnapshot().principalId, 'user-b'); + +let firstRequestResolver; +const requestAuthorizations = []; +globalThis.fetch = async (_url, options) => { + requestAuthorizations.push(new Headers(options?.headers).get('Authorization')); + return new Promise((resolve) => { + firstRequestResolver = resolve; + }); +}; + +await tokenStore.replaceSession(a1, 'refresh-a-request'); +const delayedRequest = httpClient.request('/protected-resource'); +await waitFor(() => firstRequestResolver, 'the protected request did not start'); +await tokenStore.replaceSession(b1, 'refresh-b-request'); +firstRequestResolver(new Response(JSON.stringify({ success: false, error: 'expired' }), { + status: 401, + headers: { 'Content-Type': 'application/json' } +})); +const delayedResult = await delayedRequest; +assert.equal(delayedResult.status, 401); +assert.match(delayedResult.error || '', /Session changed/); +assert.equal(requestAuthorizations.length, 1, 'an account-A request must never be replayed with account-B credentials'); +assert.equal(tokenStore.getSnapshot().principalId, 'user-b'); + +let refreshResolver; +globalThis.fetch = async () => new Promise((resolve) => { + refreshResolver = resolve; +}); +await tokenStore.replaceSession(a1, 'refresh-a-race'); +const staleRefresh = httpClient.refreshSession(); +await waitFor(() => refreshResolver, 'the refresh request did not start'); +const replacementLogin = tokenStore.replaceSession(b1, 'refresh-b-race'); +refreshResolver(new Response(JSON.stringify({ + success: true, + data: { token: a2, refreshToken: 'refresh-a-new' } +}), { + status: 200, + headers: { 'Content-Type': 'application/json' } +})); +const staleRefreshResult = await staleRefresh; +await replacementLogin; +assert.equal(staleRefreshResult.success, true, 'the refresh that already owned the lock may complete first'); +assert.equal(tokenStore.getSnapshot().principalId, 'user-b', 'an in-flight refresh must not resurrect account A'); + +const savedLocks = globalThis.navigator.locks; +delete globalThis.navigator.locks; +await assert.rejects( + tokenStore.replaceSession(a1, 'refresh-no-lock'), + /cannot safely coordinate/, + 'a browser without Web Locks must fail closed instead of using an expiring client lease' +); +globalThis.navigator.locks = savedLocks; + +console.log('Session lineage, bound dispatch, stale replay prevention, and durable logout queue checks passed.'); diff --git a/interviewhub-lld/src/App.tsx b/interviewhub-lld/src/App.tsx index a786ccdf..831656aa 100644 --- a/interviewhub-lld/src/App.tsx +++ b/interviewhub-lld/src/App.tsx @@ -1,1039 +1,4 @@ -import React, { useState, useEffect, useRef } from 'react'; -import confetti from 'canvas-confetti'; -import { ChevronRight, ChevronLeft } from 'lucide-react'; -import { - Problem, - Language, - FileNode, - ConsoleTab, - TestCase, - AiMessage, - LogEntry, - CompilerDiagnostic, - User -} from './types'; -import { TopNav } from './components/TopNav'; -import HomePage from './components/HomePage'; -import LandingPage from './components/LandingPage'; -import ProblemDetailsPage from './components/ProblemDetailsPage'; -import CompaniesPage from './components/CompaniesPage'; -import DesignPatternsHub from './components/DesignPatternsHub'; -import DesignPatternsPage from './components/DesignPatternsPage'; -import DiscussPage from './components/DiscussPage'; -import ProfilePage from './components/ProfilePage'; -import ProblemsPage from './components/ProblemsPage'; -import { ActivityBar } from './components/ActivityBar'; -import { ProblemPanel } from './components/ProblemPanel'; -import { FileExplorer } from './components/FileExplorer'; -import { EditorTabs } from './components/EditorTabs'; -import { CodeEditor } from './components/CodeEditor'; -import { BottomConsole } from './components/BottomConsole'; -import { AiInterviewer } from './components/AiInterviewer'; -import { StatusBar } from './components/StatusBar'; -import { CommandPalette } from './components/CommandPalette'; -import { ShortcutsModal } from './components/ShortcutsModal'; -import { AuthPage } from './components/AuthPage'; -import { AdminPortal } from './components/admin/AdminPortal'; -import { api } from './services/apiClient'; - -export type AppView = - | 'landing' - | 'home' - | 'problems' - | 'problem-details' - | 'ide' - | 'auth' - | 'profile' - | 'companies' - | 'design-patterns' - | 'discuss'; - -export const App: React.FC = () => { - const [currentUser, setCurrentUser] = useState(() => { - try { - const saved = localStorage.getItem('judgemycode_user'); - if (!saved) return null; - const parsed = JSON.parse(saved); - // Drop leftover Figma/demo mock users - if ( - parsed?.id === 'figma-demo-user' || - /^aman(\s+verma)?$/i.test(String(parsed?.name || '')) || - /aman\.verma@/i.test(String(parsed?.email || '')) - ) { - localStorage.removeItem('judgemycode_user'); - return null; - } - return parsed; - } catch { - return null; - } - }); - - const getInitialView = (): AppView => { - const path = window.location.pathname.toLowerCase(); - if (path === '/auth' || path === '/login' || path === '/signup') return 'auth'; - if (path === '/landing') return 'landing'; - if (path === '/profile' || path.startsWith('/u/')) return currentUser?.isLoggedIn ? 'profile' : 'auth'; - if (path === '/companies' || path.startsWith('/companies/')) return 'companies'; - if ( - path === '/design-patterns' || - path.startsWith('/design-patterns/') || - path === '/system-design' || - path.startsWith('/system-design/') - ) { - return 'design-patterns'; - } - if (path === '/discuss' || path.startsWith('/discuss/')) return 'discuss'; - if (path === '/problems' || path === '/problemset') return 'problems'; - if (path.startsWith('/problems/')) { - // Legacy problem URLs open workspace - return 'ide'; - } - if (path.startsWith('/workspace/') || path.startsWith('/problem/') || path === '/ide') return 'ide'; - if (path === '/home') return currentUser?.isLoggedIn ? 'home' : 'auth'; - if (path === '/') return currentUser?.isLoggedIn ? 'home' : 'auth'; - return currentUser?.isLoggedIn ? 'home' : 'auth'; - }; - - const [currentView, setCurrentView] = useState(getInitialView); - - const [currentProblem, setCurrentProblem] = useState(null); - const [problems, setProblems] = useState([]); - const [currentLanguage, setCurrentLanguage] = useState('java'); - const [theme, setTheme] = useState<'dark' | 'light'>('dark'); - const [fontSize, setFontSize] = useState(14); - - const [leftPanelWidth, setLeftPanelWidth] = useState(440); - const [bottomConsoleHeight, setBottomConsoleHeight] = useState(230); - const [aiPanelWidth, setAiPanelWidth] = useState(360); - const [isAiOpen, setIsAiOpen] = useState(true); - const [isProblemOpen, setIsProblemOpen] = useState(true); - const [isExplorerOpen, setIsExplorerOpen] = useState(true); - const [activeActivityView, setActiveActivityView] = useState<'problem' | 'explorer' | 'search' | 'git' | 'debug'>('problem'); - const [isConsoleMaximized, setIsConsoleMaximized] = useState(false); - - const [files, setFiles] = useState([]); - const [openFiles, setOpenFiles] = useState([]); - const [activeFile, setActiveFile] = useState(null); - const [cursorPos, setCursorPos] = useState<{ line: number; col: number }>({ line: 1, col: 1 }); - const [isSaving, setIsSaving] = useState(false); - - const [consoleTab, setConsoleTab] = useState('console'); - const [consoleOutput, setConsoleOutput] = useState([]); - const [testCases, setTestCases] = useState([]); - const [isRunning, setIsRunning] = useState(false); - const [logs, setLogs] = useState([]); - const [diagnostics, setDiagnostics] = useState([]); - - const [aiMessages, setAiMessages] = useState([ - { - id: 'intro-1', - role: 'assistant', - content: `Hi! I'm your AI interviewer. I will evaluate your Low-Level Design (LLD) on SOLID principles, modularity, edge cases, and concurrency.\n\nFeel free to ask for hints, design reviews, or clarify requirements at any time!`, - timestamp: '—' - } - ]); - const [isAiLoading, setIsAiLoading] = useState(false); - - const [isCommandPaletteOpen, setIsCommandPaletteOpen] = useState(false); - const [isShortcutsModalOpen, setIsShortcutsModalOpen] = useState(false); - const [isAdminPortalOpen, setIsAdminPortalOpen] = useState(false); - - const isDraggingLeftRef = useRef(false); - const isDraggingConsoleRef = useRef(false); - const isDraggingAiRef = useRef(false); - - const [solvedProblemIds, setSolvedProblemIds] = useState([]); - - useEffect(() => { - if (!currentProblem) { - setFiles([]); - setTestCases([]); - setOpenFiles([]); - setActiveFile(null); - return; - } - - const starter = currentProblem.starterFiles?.[currentLanguage] || currentProblem.starterFiles?.java || []; - setFiles(starter); - setTestCases(currentProblem.testCases || []); - - const findMain = (nodes: FileNode[]): FileNode | null => { - for (const node of nodes) { - if (!node.isDirectory) { - if (node.name.toLowerCase().includes('main') || node.name.toLowerCase().includes('app')) { - return node; - } - } - if (node.children) { - const found = findMain(node.children); - if (found) return found; - } - } - return nodes[0]?.isDirectory && nodes[0].children ? nodes[0].children[0] : nodes[0] || null; - }; - - const mainFile = findMain(starter); - if (mainFile) { - setOpenFiles([mainFile]); - setActiveFile(mainFile); - } else { - setOpenFiles([]); - setActiveFile(null); - } - }, [currentProblem, currentLanguage]); - - useEffect(() => { - api.getProblems().then((catalog) => { - setProblems(catalog); - const path = window.location.pathname.toLowerCase(); - const slugMatch = path.match(/^\/(?:workspace|problem|problems)\/([^/]+)/); - const slug = slugMatch?.[1]; - setCurrentProblem((selected) => { - if (catalog.length === 0) return null; - if (slug) { - const fromUrl = catalog.find((problem) => problem.id.toLowerCase() === slug); - if (fromUrl) return fromUrl; - } - if (selected) return catalog.find((problem) => problem.id === selected.id) || catalog[0]; - return catalog[0]; - }); - if (path.startsWith('/problems/') && slug) { - window.history.replaceState({ view: 'ide', problemId: slug }, '', `/workspace/${slug}`); - } - }); - }, []); - - // Derive solved problem IDs from real submissions (no local mock) - useEffect(() => { - if (!currentUser?.isLoggedIn) { - setSolvedProblemIds([]); - return; - } - api.getSubmissions().then((res) => { - if (!res.success || !Array.isArray(res.data)) { - setSolvedProblemIds([]); - return; - } - const accepted = res.data - .filter((s: any) => String(s.status || '').toUpperCase() === 'ACCEPTED') - .map((s: any) => s.problemId) - .filter(Boolean); - setSolvedProblemIds([...new Set(accepted as string[])]); - }); - }, [currentUser?.isLoggedIn, currentUser?.id]); - - const [oauthError, setOauthError] = useState(''); - - useEffect(() => { - const params = new URLSearchParams(window.location.search); - const oauthErr = params.get('oauth_error'); - const oauthCode = params.get('oauth_code'); - // Legacy query tokens are ignored — tokens must never live in URLs. - - const bootstrap = async () => { - if (oauthErr) { - setOauthError(decodeURIComponent(oauthErr)); - setCurrentView('auth'); - window.history.replaceState({}, document.title, window.location.pathname); - } else if (oauthCode) { - window.history.replaceState({}, document.title, window.location.pathname); - const exchanged = await api.exchangeOAuthCode(oauthCode); - if (!exchanged.success) { - setOauthError(exchanged.error || 'OAuth sign-in failed'); - setCurrentView('auth'); - } - } - - if (!api.getToken() && !api.getRefreshToken()) return; - let response = await api.getMe(); - if (!response.success && api.getRefreshToken()) { - const refreshed = await api.refreshSession(); - response = refreshed.success ? await api.getMe() : response; - } - if (!response.success || !response.data) { - api.logout(); - setCurrentUser(null); - localStorage.removeItem('judgemycode_user'); - return; - } - const account = response.data; - const user: User = { - id: account.id, - name: account.name, - email: account.email, - avatar: account.avatar || `https://api.dicebear.com/7.x/bottts/svg?seed=${encodeURIComponent(account.name)}`, - role: account.role, - authProvider: account.authProvider, - isLoggedIn: true - }; - setCurrentUser(user); - localStorage.setItem('judgemycode_user', JSON.stringify(user)); - if (window.location.pathname === '/' || window.location.pathname.toLowerCase() === '/auth') { - navigateTo('home'); - } - }; - void bootstrap(); - }, []); - - // Entry flow: Login/Sign up first; after auth → Home. Landing is not the first page. - useEffect(() => { - const path = window.location.pathname.toLowerCase(); - if (path === '/') { - if (currentUser?.isLoggedIn) { - window.history.replaceState({ view: 'home' }, '', '/home'); - setCurrentView('home'); - } else { - window.history.replaceState({ view: 'auth' }, '', '/login'); - setCurrentView('auth'); - } - } else if ((path === '/home' || path === '/landing') && !currentUser?.isLoggedIn) { - window.history.replaceState({ view: 'auth' }, '', '/login'); - setCurrentView('auth'); - } - }, [currentUser]); - - useEffect(() => { - const handleMouseMove = (e: MouseEvent) => { - if (isDraggingLeftRef.current) { - const newWidth = Math.max(300, Math.min(750, e.clientX - 48)); - setLeftPanelWidth(newWidth); - } else if (isDraggingConsoleRef.current) { - const newHeight = Math.max(120, Math.min(550, window.innerHeight - e.clientY - 24)); - setBottomConsoleHeight(newHeight); - } else if (isDraggingAiRef.current) { - const newWidth = Math.max(280, Math.min(600, window.innerWidth - e.clientX)); - setAiPanelWidth(newWidth); - } - }; - - const handleMouseUp = () => { - isDraggingLeftRef.current = false; - isDraggingConsoleRef.current = false; - isDraggingAiRef.current = false; - }; - - window.addEventListener('mousemove', handleMouseMove); - window.addEventListener('mouseup', handleMouseUp); - - return () => { - window.removeEventListener('mousemove', handleMouseMove); - window.removeEventListener('mouseup', handleMouseUp); - }; - }, []); - - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') { - e.preventDefault(); - setIsCommandPaletteOpen(prev => !prev); - } else if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'b') { - e.preventDefault(); - setIsExplorerOpen(prev => !prev); - } else if ((e.metaKey || e.ctrlKey) && e.key === "'") { - e.preventDefault(); - handleRunCode(); - } else if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { - e.preventDefault(); - handleSubmitCode(); - } - }; - - window.addEventListener('keydown', handleKeyDown); - return () => window.removeEventListener('keydown', handleKeyDown); - }, [activeFile, files]); - - const handleSelectFile = (file: FileNode) => { - if (!openFiles.some(f => f.id === file.id)) { - setOpenFiles([...openFiles, file]); - } - setActiveFile(file); - }; - - const handleCloseTab = (file: FileNode | string, e?: React.MouseEvent) => { - e?.stopPropagation(); - const fileId = typeof file === 'string' ? file : file.id; - const nextOpen = openFiles.filter(f => f.id !== fileId); - setOpenFiles(nextOpen); - if (activeFile?.id === fileId) { - setActiveFile(nextOpen.length > 0 ? nextOpen[nextOpen.length - 1] : null); - } - }; - - const handleCloseOtherTabs = () => { - if (!activeFile) return; - setOpenFiles([activeFile]); - }; - - const handleCloseAllTabs = () => { - setOpenFiles([]); - setActiveFile(null); - }; - - const handleContentChange = (newContent: string) => { - if (!activeFile) return; - - setIsSaving(true); - setTimeout(() => setIsSaving(false), 500); - - const updateTree = (nodes: FileNode[]): FileNode[] => { - return nodes.map(node => { - if (node.id === activeFile.id) { - return { ...node, content: newContent, isDirty: true }; - } - if (node.children) { - return { ...node, children: updateTree(node.children) }; - } - return node; - }); - }; - - const updatedFiles = updateTree(files); - setFiles(updatedFiles); - setActiveFile({ ...activeFile, content: newContent, isDirty: true }); - setOpenFiles(openFiles.map(f => f.id === activeFile.id ? { ...f, content: newContent, isDirty: true } : f)); - }; - - const handleCreateFile = (parentPath: string, fileName: string, isDirectory: boolean) => { - const newPath = `${parentPath}/${fileName}`; - const newNode: FileNode = { - id: `file-${Date.now()}`, - name: fileName, - path: newPath, - isDirectory, - content: isDirectory ? undefined : `// ${fileName}\n\n`, - children: isDirectory ? [] : undefined - }; - - const addToTree = (nodes: FileNode[]): FileNode[] => { - return nodes.map(node => { - if (node.path === parentPath && node.isDirectory) { - return { - ...node, - children: [...(node.children || []), newNode] - }; - } - if (node.children) { - return { ...node, children: addToTree(node.children) }; - } - return node; - }); - }; - - const updated = addToTree(files); - setFiles(updated); - if (!isDirectory) { - setOpenFiles([...openFiles, newNode]); - setActiveFile(newNode); - } - }; - - const executionPayload = () => ({ - problemId: currentProblem?.id || '', - language: currentLanguage, - files - }); - - const handleRunCode = async () => { - setIsRunning(true); - setConsoleTab('console'); - setConsoleOutput(prev => [...prev, '', `[Compiling ${activeFile?.name || 'Main.java'}...]`]); - try { - const response = await api.runCode(executionPayload()); - const result = response.data; - if (!response.success || !result) throw new Error(response.error || 'Execution failed'); - const output = [result.stdout, result.stderr ? `[STDERR] ${result.stderr}` : ''].filter(Boolean); - setConsoleOutput(prev => [...prev, ...output]); - setTestCases(prev => prev.map(tc => { - const resultCase = result.testResults?.find((item: any) => item.testCaseId === tc.id); - return resultCase ? { ...tc, status: resultCase.passed ? 'passed' : 'failed', durationMs: resultCase.durationMs, actualOutput: resultCase.actual } : tc; - })); - } catch (error: any) { - setConsoleOutput(prev => [...prev, `[ERROR] ${error.message || 'Unable to reach the execution service.'}`]); - } finally { - setIsRunning(false); - } - }; - - const handleSubmitCode = async () => { - setIsRunning(true); - setConsoleTab('test-results'); - setTestCases(testCases.map(tc => ({ ...tc, status: 'running' }))); - try { - const response = await api.submitCode(executionPayload()); - const submission = response.data; - if (!response.success || !submission) throw new Error(response.error || 'Submission failed'); - setTestCases(prev => prev.map(tc => { - const resultCase = submission.testResults?.find((item: any) => item.testCaseId === tc.id); - return resultCase ? { ...tc, status: resultCase.passed ? 'passed' : 'failed', durationMs: resultCase.durationMs, actualOutput: resultCase.actual } : tc; - })); - if (submission.status === 'ACCEPTED') { - confetti({ particleCount: 100, spread: 70, origin: { y: 0.6 } }); - if (currentProblem) { - setSolvedProblemIds((prev) => - prev.includes(currentProblem.id) ? prev : [...prev, currentProblem.id] - ); - } - } - } catch (error: any) { - setConsoleOutput(prev => [...prev, `[ERROR] ${error.message || 'Sign in to submit your solution.'}`]); - } finally { - setIsRunning(false); - } - }; - - const handleRunSingleTest = async (testId: string) => { - setTestCases(prev => prev.map(tc => tc.id === testId ? { ...tc, status: 'running' } : tc)); - try { - const response = await api.runCode(executionPayload()); - const result = response.data; - if (!response.success || !result) throw new Error(response.error || 'Execution failed'); - setTestCases(prev => prev.map(tc => { - if (tc.id !== testId) return tc; - const resultCase = result.testResults?.find((item: any) => item.testCaseId === tc.id); - if (!resultCase) return { ...tc, status: 'failed' }; - return { - ...tc, - status: resultCase.passed ? 'passed' : 'failed', - durationMs: resultCase.durationMs, - actualOutput: resultCase.actual - }; - })); - } catch { - setTestCases(prev => prev.map(tc => tc.id === testId ? { ...tc, status: 'failed' } : tc)); - } - }; - - const handleSendAiMessage = async (prompt: string, category?: AiMessage['category']) => { - const userMsg: AiMessage = { - id: `msg-${Date.now()}`, - role: 'user', - content: prompt, - timestamp: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }), - category - }; - - setAiMessages(prev => [...prev, userMsg]); - setIsAiLoading(true); - - try { - if (!currentProblem) { - setAiMessages(prev => [...prev, { - id: `ai-${Date.now()}`, - role: 'assistant', - content: 'Open a problem workspace first to get interview feedback.', - timestamp: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) - }]); - return; - } - const result = await api.sendAiMessage(currentProblem.id, prompt, activeFile?.content || ''); - const response = result.success && result.data - ? result.data.content - : (result.error || 'AI interviewer is unavailable. No local mock responses are used.'); - - const aiMsg: AiMessage = { - id: `ai-${Date.now()}`, - role: 'assistant', - content: response, - timestamp: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) - }; - setAiMessages(prev => [...prev, aiMsg]); - } finally { - setIsAiLoading(false); - } - }; - - const navigateTo = ( - view: AppView | string, - problem?: Problem, - pushHistory = true - ) => { - let targetView: AppView = 'auth'; - let path = '/login'; - - const normalizedView = view.toLowerCase(); - - if (normalizedView === 'landing') { - // Landing kept for optional marketing, but entry flow uses auth - targetView = 'landing'; - path = '/landing'; - } else if (normalizedView === 'home' || normalizedView === 'dashboard') { - targetView = currentUser?.isLoggedIn ? 'home' : 'auth'; - path = currentUser?.isLoggedIn ? '/home' : '/login'; - } else if (normalizedView === 'problems' || normalizedView === 'problemset') { - targetView = 'problems'; - path = '/problems'; - } else if (normalizedView.startsWith('problems/')) { - // Problem clicks open the workspace (statement + editor), not a separate details page - targetView = 'ide'; - const slug = normalizedView.replace('problems/', ''); - const found = problems.find((p) => p.id.toLowerCase() === slug) || problem; - if (found) setCurrentProblem(found); - path = `/workspace/${found ? found.id : slug}`; - } else if (normalizedView.startsWith('workspace/') || normalizedView.startsWith('problem/') || normalizedView === 'ide') { - targetView = 'ide'; - const p = problem || currentProblem; - if (problem) setCurrentProblem(problem); - if (!p) { - targetView = 'problems'; - path = '/problems'; - } else { - path = `/workspace/${p.id}`; - } - } else if (normalizedView === 'companies' || normalizedView.startsWith('companies/')) { - targetView = 'companies'; - path = normalizedView.startsWith('companies/') ? `/${normalizedView}` : '/companies'; - } else if ( - normalizedView === 'design-patterns' || - normalizedView.startsWith('design-patterns/') || - normalizedView === 'system-design' || - normalizedView.startsWith('system-design/') - ) { - targetView = 'design-patterns'; - const slug = normalizedView.includes('/') - ? normalizedView.split('/').slice(1).join('/') - : ''; - path = slug ? `/design-patterns/${slug}` : '/design-patterns'; - } else if (normalizedView === 'discuss' || normalizedView.startsWith('discuss/')) { - targetView = 'discuss'; - path = normalizedView.startsWith('discuss/') ? `/${normalizedView}` : '/discuss'; - } else if (normalizedView === 'profile' || normalizedView.startsWith('u/')) { - targetView = 'profile'; - path = currentUser?.name ? `/u/${encodeURIComponent(currentUser.name.toLowerCase())}` : '/profile'; - } else if (normalizedView === 'auth' || normalizedView === 'login' || normalizedView === 'signup') { - targetView = 'auth'; - path = normalizedView === 'signup' ? '/signup' : '/login'; - } else { - targetView = currentUser?.isLoggedIn ? 'home' : 'auth'; - path = currentUser?.isLoggedIn ? '/home' : '/login'; - } - - setCurrentView(targetView); - - if (pushHistory && window.location.pathname.toLowerCase() !== path.toLowerCase()) { - window.history.pushState({ view: targetView, problemId: problem?.id }, '', path); - } - }; - - useEffect(() => { - const handlePopState = () => { - const path = window.location.pathname.toLowerCase(); - if (path === '/' || path === '/home') { - setCurrentView(currentUser?.isLoggedIn ? 'home' : 'auth'); - } else if (path === '/landing') { - setCurrentView('landing'); - } else if (path === '/problems' || path === '/problemset') { - setCurrentView('problems'); - } else if (path.startsWith('/problems/')) { - const slug = path.replace('/problems/', ''); - const found = problems.find((p) => p.id.toLowerCase() === slug); - if (found) setCurrentProblem(found); - setCurrentView('ide'); - window.history.replaceState({ view: 'ide', problemId: found?.id }, '', `/workspace/${found ? found.id : slug}`); - } else if (path.startsWith('/workspace/') || path.startsWith('/problem/') || path === '/ide') { - const slug = path.replace('/workspace/', '').replace('/problem/', '').replace('/ide', ''); - if (slug) { - const found = problems.find((p) => p.id.toLowerCase() === slug); - if (found) setCurrentProblem(found); - } - setCurrentView('ide'); - } else if (path === '/companies' || path.startsWith('/companies/')) { - setCurrentView('companies'); - } else if ( - path === '/design-patterns' || - path.startsWith('/design-patterns/') || - path === '/system-design' || - path.startsWith('/system-design/') - ) { - setCurrentView('design-patterns'); - if (path.startsWith('/system-design')) { - window.history.replaceState({ view: 'design-patterns' }, '', '/design-patterns'); - } - } else if (path === '/discuss' || path.startsWith('/discuss/')) { - setCurrentView('discuss'); - } else if (path === '/profile' || path.startsWith('/u/')) { - setCurrentView('profile'); - } else if (path === '/auth' || path === '/login' || path === '/signup') { - setCurrentView('auth'); - } - }; - - window.addEventListener('popstate', handlePopState); - return () => window.removeEventListener('popstate', handlePopState); - }, [problems, currentUser]); - - const handleOpenProblem = (problem: Problem) => { - setCurrentProblem(problem); - navigateTo('workspace/' + problem.id, problem); - }; - - const handleOpenWorkspace = (problem: Problem) => { - setCurrentProblem(problem); - navigateTo('workspace/' + problem.id, problem); - }; - - const handleUpdateProfile = (updatedUser: User) => { - setCurrentUser(updatedUser); - localStorage.setItem('judgemycode_user', JSON.stringify(updatedUser)); - }; - - const handleSignOut = () => { - api.logout(); - setCurrentUser(null); - localStorage.removeItem('judgemycode_user'); - navigateTo('login'); - }; - - if (currentView === 'auth') { - const authPath = window.location.pathname.toLowerCase(); - return ( - { - setOauthError(''); - handleUpdateProfile(user); - navigateTo('home'); - }} - /> - ); - } - - return ( -
- {currentView === 'landing' && ( - navigateTo('problems')} - onOpenAuth={() => navigateTo('auth')} - onOpenPlayground={() => navigateTo('problems')} - onOpenSystemDesign={() => navigateTo('design-patterns')} - onOpenCompanies={() => navigateTo('companies')} - /> - )} - - {currentView === 'ide' && currentProblem && ( - { - setCurrentProblem(prob); - navigateTo('workspace/' + prob.id, prob); - }} - currentLanguage={currentLanguage} - onSelectLanguage={setCurrentLanguage} - theme={theme} - onToggleTheme={() => setTheme(theme === 'dark' ? 'light' : 'dark')} - fontSize={fontSize} - onChangeFontSize={setFontSize} - isRunning={isRunning} - onRunCode={handleRunCode} - onSubmitCode={handleSubmitCode} - isAiOpen={isAiOpen} - onToggleAi={() => setIsAiOpen(!isAiOpen)} - onOpenCommandPalette={() => setIsCommandPaletteOpen(true)} - onOpenAdminPortal={() => setIsAdminPortalOpen(true)} - currentUser={currentUser} - onOpenAuth={() => navigateTo('auth')} - onSignOut={handleSignOut} - onOpenHome={() => navigateTo('home')} - onOpenProblems={() => navigateTo('problems')} - onOpenProfile={() => navigateTo('profile')} - /> - )} - - {currentView === 'home' && ( - navigateTo('profile')} - onOpenAdmin={() => setIsAdminPortalOpen(true)} - onOpenProblem={handleOpenProblem} - onOpenCommandPalette={() => setIsCommandPaletteOpen(true)} - onOpenAuth={() => navigateTo('auth')} - onSignOut={handleSignOut} - onNavigate={(v) => navigateTo(v)} - /> - )} - - {currentView === 'problems' && ( - navigateTo(v)} - currentUser={currentUser} - onOpenAuth={() => navigateTo('auth')} - onSignOut={handleSignOut} - /> - )} - - {currentView === 'problem-details' && currentProblem && ( - navigateTo(v, p)} - /> - )} - - {currentView === 'problem-details' && !currentProblem && ( -
Problem not found.
- )} - - {currentView === 'companies' && ( - navigateTo(v, p)} - currentUser={currentUser} - /> - )} - - {currentView === 'design-patterns' && (() => { - const patternSlug = window.location.pathname.match(/^\/design-patterns\/([^/]+)/i)?.[1]; - if (!patternSlug) { - return ( - navigateTo(`design-patterns/${id}`)} - onNavigate={(v, p) => navigateTo(v, p)} - currentUser={currentUser} - onOpenCommandPalette={() => setIsCommandPaletteOpen(true)} - /> - ); - } - return ( - navigateTo(v, p)} - currentUser={currentUser} - onOpenCommandPalette={() => setIsCommandPaletteOpen(true)} - initialPatternId={patternSlug} - /> - ); - })()} - - {currentView === 'discuss' && ( - navigateTo(v, p)} - currentUser={currentUser} - /> - )} - - {currentView === 'profile' && ( - solvedProblemIds.includes(problem.id))} - onUpdateProfile={handleUpdateProfile} - onOpenHome={() => navigateTo('home')} - onOpenProblems={() => navigateTo('problems')} - onOpenProblem={handleOpenProblem} - onSignOut={handleSignOut} - onNavigate={(v) => navigateTo(v)} - /> - )} - - {currentView === 'ide' && !currentProblem && ( -
- No problem loaded.{' '} - -
- )} - - {currentView === 'ide' && currentProblem && ( - <> -
- setIsProblemOpen(!isProblemOpen)} - isExplorerOpen={isExplorerOpen} - onToggleExplorer={() => setIsExplorerOpen(!isExplorerOpen)} - isAiOpen={isAiOpen} - onToggleAi={() => setIsAiOpen(!isAiOpen)} - onOpenShortcuts={() => setIsShortcutsModalOpen(true)} - currentUser={currentUser} - onOpenAuth={() => setCurrentView('auth')} - onSignOut={handleSignOut} - /> - - {isProblemOpen ? ( - <> - setIsProblemOpen(false)} - /> -
{ isDraggingLeftRef.current = true; }} - className="split-resizer vertical" - title="Drag to resize problem panel" - /> - - ) : ( -
setIsProblemOpen(true)} - title="Click to expand Problem Statement" - className="w-8 bg-[#161b22] border-r border-[#30363d] flex flex-col items-center py-3 gap-3 select-none text-neutral-400 hover:text-white hover:bg-[#1c2128] cursor-pointer group transition-all shrink-0 z-10" - > - - - Problem Statement - -
- )} - -
-
- {isExplorerOpen && ( - setIsExplorerOpen(false)} - /> - )} - -
- handleCreateFile('src', `NewFile_${Date.now().toString().slice(-4)}.java`, false)} - isExplorerOpen={isExplorerOpen} - onToggleExplorer={() => setIsExplorerOpen(!isExplorerOpen)} - /> - -
- setCursorPos({ line, col })} - /> -
-
-
- -
{ isDraggingConsoleRef.current = true; }} - className="split-resizer horizontal" - title="Drag to resize console" - /> - - setConsoleOutput(prev => [...prev, ...lines])} - testCases={testCases} - logs={logs} - diagnostics={diagnostics} - onClearConsole={() => setConsoleOutput([])} - onRunSingleTest={handleRunSingleTest} - onRunAllTests={handleSubmitCode} - isMaximized={isConsoleMaximized} - onToggleMaximize={() => setIsConsoleMaximized(!isConsoleMaximized)} - /> -
- - {isAiOpen && ( - <> -
{ isDraggingAiRef.current = true; }} - className="split-resizer vertical" - title="Drag to resize AI panel" - /> - - setIsAiOpen(false)} - width={aiPanelWidth} - /> - - )} -
- - setIsShortcutsModalOpen(true)} - /> - - setIsCommandPaletteOpen(false)} - onRunCode={handleRunCode} - onSubmitCode={handleSubmitCode} - onRunTests={handleSubmitCode} - onClearConsole={() => setConsoleOutput([])} - onToggleTheme={() => setTheme(theme === 'dark' ? 'light' : 'dark')} - onToggleAi={() => setIsAiOpen(!isAiOpen)} - problems={problems} - onSelectProblem={setCurrentProblem} - onSelectLanguage={setCurrentLanguage} - /> - - setIsShortcutsModalOpen(false)} - /> - - {isAdminPortalOpen && ( - { - setCurrentProblem(problem); - setIsAdminPortalOpen(false); - setCurrentView('ide'); - }} - onRefreshProblems={async () => { - const catalog = await api.getProblems(); - setProblems(catalog); - }} - onClose={() => setIsAdminPortalOpen(false)} - /> - )} - - )} - - {currentView !== 'ide' && isAdminPortalOpen && ( - { - setCurrentProblem(problem); - setIsAdminPortalOpen(false); - setCurrentView('ide'); - }} - onRefreshProblems={async () => { - const catalog = await api.getProblems(); - setProblems(catalog); - }} - onClose={() => setIsAdminPortalOpen(false)} - /> - )} -
- ); -}; - -export default App; +// Compatibility entrypoint for existing imports. Application composition lives +// in the app layer; feature and shared modules stay independently testable. +export { App, default } from './app/App'; +export type { AppView } from './app/App'; diff --git a/interviewhub-lld/src/app/App.tsx b/interviewhub-lld/src/app/App.tsx new file mode 100644 index 00000000..26debb1a --- /dev/null +++ b/interviewhub-lld/src/app/App.tsx @@ -0,0 +1,1093 @@ +import React, { useState, useEffect, useRef } from 'react'; +import confetti from 'canvas-confetti'; +import { ChevronRight, ChevronLeft } from 'lucide-react'; +import { + Problem, + Language, + ConsoleTab, + TestCase, + AiMessage, + LogEntry, + CompilerDiagnostic, + User +} from '../types'; +import { TopNav } from '../components/TopNav'; +import HomePage from '../components/HomePage'; +import LandingPage from '../components/LandingPage'; +import ProblemDetailsPage from '../components/ProblemDetailsPage'; +import CompaniesPage from '../components/CompaniesPage'; +import DesignPatternsHub from '../components/DesignPatternsHub'; +import DesignPatternsPage from '../components/DesignPatternsPage'; +import DiscussPage from '../components/DiscussPage'; +import ProfilePage from '../components/ProfilePage'; +import ProblemsPage from '../components/ProblemsPage'; +import { ActivityBar } from '../components/ActivityBar'; +import { ProblemPanel } from '../components/ProblemPanel'; +import { FileExplorer } from '../components/FileExplorer'; +import { EditorTabs } from '../components/EditorTabs'; +import { BottomConsole } from '../components/BottomConsole'; +import { AiInterviewer } from '../components/AiInterviewer'; +import { StatusBar } from '../components/StatusBar'; +import { CommandPalette } from '../components/CommandPalette'; +import { ShortcutsModal } from '../components/ShortcutsModal'; +import { AuthPage } from '../components/AuthPage'; +import { AdminPortal } from '../components/admin/AdminPortal'; +import { api } from '../services/apiClient'; +import { useSession } from '../features/auth/useSession'; +import { useExecutionJob } from '../features/execution/hooks/useExecutionJob'; +import type { ExecutionResult } from '../features/execution/api'; +import { useWorkspace } from '../features/workspace/hooks/useWorkspace'; +import { defaultFileName } from '../features/workspace/lib/fileTree'; +import { tokenStore } from '../shared/api'; + +const CodeEditor = React.lazy(async () => { + const module = await import('../components/CodeEditor'); + return { default: module.CodeEditor }; +}); + +const initialAiMessages = (): AiMessage[] => [{ + id: 'intro-1', + role: 'assistant', + content: `Hi! I'm your AI interviewer. I will evaluate your Low-Level Design (LLD) on SOLID principles, modularity, edge cases, and concurrency.\n\nFeel free to ask for hints, design reviews, or clarify requirements at any time!`, + timestamp: '—' +}]; + +const currentSessionBoundary = (): string => { + const tokens = tokenStore.getSnapshot(); + return `${tokens.principalId || ''}\u0000${tokens.sessionEpoch || ''}`; +}; + +interface UserWorkContext { + sessionBoundary: string; + generation: number; + problemId: string; + language: Language; +} + +export type AppView = + | 'landing' + | 'home' + | 'problems' + | 'problem-details' + | 'ide' + | 'auth' + | 'profile' + | 'companies' + | 'design-patterns' + | 'discuss'; + +export const App: React.FC = () => { + const session = useSession(); + const currentUser = session.user; + + const getInitialView = (): AppView => { + const path = window.location.pathname.toLowerCase(); + if (path === '/auth' || path === '/login' || path === '/signup') return 'auth'; + if (path === '/landing') return 'landing'; + if (path === '/profile' || path.startsWith('/u/')) return 'profile'; + if (path === '/companies' || path.startsWith('/companies/')) return 'companies'; + if ( + path === '/design-patterns' || + path.startsWith('/design-patterns/') || + path === '/system-design' || + path.startsWith('/system-design/') + ) { + return 'design-patterns'; + } + if (path === '/discuss' || path.startsWith('/discuss/')) return 'discuss'; + if (path === '/problems' || path === '/problemset') return 'problems'; + if (path.startsWith('/problems/')) { + // Legacy problem URLs open workspace + return 'ide'; + } + if (path.startsWith('/workspace/') || path.startsWith('/problem/') || path === '/ide') return 'ide'; + if (path === '/home') return 'home'; + if (path === '/') return 'auth'; + return 'auth'; + }; + + const [currentView, setCurrentView] = useState(getInitialView); + + const [currentProblem, setCurrentProblem] = useState(null); + const [problems, setProblems] = useState([]); + const [currentLanguage, setCurrentLanguage] = useState('java'); + const [theme, setTheme] = useState<'dark' | 'light'>('dark'); + const [fontSize, setFontSize] = useState(14); + + const [leftPanelWidth, setLeftPanelWidth] = useState(440); + const [bottomConsoleHeight, setBottomConsoleHeight] = useState(230); + const [aiPanelWidth, setAiPanelWidth] = useState(360); + const [isAiOpen, setIsAiOpen] = useState(true); + const [isProblemOpen, setIsProblemOpen] = useState(true); + const [isExplorerOpen, setIsExplorerOpen] = useState(true); + const [activeActivityView, setActiveActivityView] = useState<'problem' | 'explorer' | 'search' | 'git' | 'debug'>('problem'); + const [isConsoleMaximized, setIsConsoleMaximized] = useState(false); + + const [cursorPos, setCursorPos] = useState<{ line: number; col: number }>({ line: 1, col: 1 }); + + const [consoleTab, setConsoleTab] = useState('console'); + const [consoleOutput, setConsoleOutput] = useState([]); + const [testCases, setTestCases] = useState([]); + const [customInput, setCustomInput] = useState(''); + const [logs, setLogs] = useState([]); + const [diagnostics, setDiagnostics] = useState([]); + + const [aiMessages, setAiMessages] = useState(initialAiMessages); + const [isAiLoading, setIsAiLoading] = useState(false); + + const [isCommandPaletteOpen, setIsCommandPaletteOpen] = useState(false); + const [isShortcutsModalOpen, setIsShortcutsModalOpen] = useState(false); + const [isAdminPortalOpen, setIsAdminPortalOpen] = useState(false); + + const isDraggingLeftRef = useRef(false); + const isDraggingConsoleRef = useRef(false); + const isDraggingAiRef = useRef(false); + const shortcutActionsRef = useRef({ run: () => {}, submit: () => {} }); + const aiAbortRef = useRef(null); + const userWorkGenerationRef = useRef(0); + const currentProblemIdRef = useRef(''); + const currentLanguageRef = useRef('java'); + + const [solvedProblemIds, setSolvedProblemIds] = useState([]); + const workspace = useWorkspace({ + problem: currentProblem, + language: currentLanguage, + authenticated: session.status === 'authenticated', + userId: currentUser?.id + }); + const execution = useExecutionJob(); + currentProblemIdRef.current = currentProblem?.id || ''; + currentLanguageRef.current = currentLanguage; + const { + files, + openFiles, + activeFile, + saveStatus, + saveError, + hasConflict, + keepLocalVersion, + useServerVersion, + selectFile: handleSelectFile, + closeTab: handleCloseTab, + closeOtherTabs: handleCloseOtherTabs, + closeAllTabs: handleCloseAllTabs, + changeContent: handleContentChange, + createFile: handleCreateFile + } = workspace; + const isRunning = execution.isRunning; + const sessionBoundary = `${session.principalId || ''}\u0000${session.sessionEpoch || ''}`; + + const captureUserWorkContext = (): UserWorkContext => ({ + sessionBoundary: currentSessionBoundary(), + generation: userWorkGenerationRef.current, + problemId: currentProblemIdRef.current, + language: currentLanguageRef.current + }); + + const isCurrentUserWork = (context: UserWorkContext): boolean => { + return context.sessionBoundary === currentSessionBoundary() && + context.generation === userWorkGenerationRef.current && + context.problemId === currentProblemIdRef.current && + context.language === currentLanguageRef.current; + }; + + useEffect(() => { + // A login epoch changes on logout and every fresh login, including a + // different account in another tab. Clear all account-derived UI and make + // late execution/AI completions unable to commit into the next session. + userWorkGenerationRef.current += 1; + execution.cancel(); + aiAbortRef.current?.abort(); + aiAbortRef.current = null; + setConsoleTab('console'); + setConsoleOutput([]); + setTestCases([]); + setCustomInput(''); + setLogs([]); + setDiagnostics([]); + setAiMessages(initialAiMessages()); + setIsAiLoading(false); + setSolvedProblemIds([]); + setProblems([]); + setCurrentProblem(null); + setIsAdminPortalOpen(false); + setIsCommandPaletteOpen(false); + setIsShortcutsModalOpen(false); + }, [sessionBoundary]); + + useEffect(() => { + setTestCases(currentProblem?.testCases || []); + }, [currentProblem]); + + useEffect(() => { + // Execution and interview feedback belong to one problem/language context. + // Switching either context cancels old work and prevents its late result + // from appearing in the newly selected workspace. + userWorkGenerationRef.current += 1; + execution.cancel(); + aiAbortRef.current?.abort(); + aiAbortRef.current = null; + setConsoleTab('console'); + setConsoleOutput([]); + setTestCases(currentProblem?.testCases || []); + setCustomInput(''); + setLogs([]); + setDiagnostics([]); + setAiMessages(initialAiMessages()); + setIsAiLoading(false); + }, [currentProblem?.id, currentLanguage]); + + useEffect(() => { + const expectedBoundary = currentSessionBoundary(); + api.getProblems().then((catalog) => { + if (currentSessionBoundary() !== expectedBoundary) return; + setProblems(catalog); + const path = window.location.pathname.toLowerCase(); + const slugMatch = path.match(/^\/(?:workspace|problem|problems)\/([^/]+)/); + const slug = slugMatch?.[1]; + setCurrentProblem((selected) => { + if (catalog.length === 0) return null; + if (slug) { + const fromUrl = catalog.find((problem) => problem.id.toLowerCase() === slug); + if (fromUrl) return fromUrl; + } + if (selected) return catalog.find((problem) => problem.id === selected.id) || catalog[0]; + return catalog[0]; + }); + if (path.startsWith('/problems/') && slug) { + window.history.replaceState({ view: 'ide', problemId: slug }, '', `/workspace/${slug}`); + } + }); + }, [sessionBoundary]); + + // Progress is aggregated by PostgreSQL. Loading bounded submission-history + // pages here would make correctness depend on how much history was fetched. + useEffect(() => { + if (!currentUser?.isLoggedIn) { + setSolvedProblemIds([]); + return; + } + const expectedBoundary = currentSessionBoundary(); + setSolvedProblemIds([]); + api.getMyProgress().then((res) => { + if (currentSessionBoundary() !== expectedBoundary) return; + if (!res.success || !res.data || !Array.isArray(res.data.solvedProblemIds)) { + setSolvedProblemIds([]); + return; + } + setSolvedProblemIds([...new Set(res.data.solvedProblemIds.filter(Boolean))]); + }); + }, [currentUser?.isLoggedIn, currentUser?.id, sessionBoundary]); + + // Entry flow: Login/Sign up first; after auth → Home. Landing is not the first page. + useEffect(() => { + if (session.status === 'loading' || session.status === 'unavailable') return; + const path = window.location.pathname.toLowerCase(); + if (path === '/') { + if (currentUser?.isLoggedIn) { + window.history.replaceState({ view: 'home' }, '', '/home'); + setCurrentView('home'); + } else { + window.history.replaceState({ view: 'auth' }, '', '/login'); + setCurrentView('auth'); + } + } else if ((path === '/home' || path === '/landing' || path === '/profile' || path.startsWith('/u/')) && !currentUser?.isLoggedIn) { + window.history.replaceState({ view: 'auth' }, '', '/login'); + setCurrentView('auth'); + } else if ((path === '/auth' || path === '/login' || path === '/signup') && currentUser?.isLoggedIn) { + window.history.replaceState({ view: 'home' }, '', '/home'); + setCurrentView('home'); + } + }, [currentUser, session.status]); + + useEffect(() => { + const handleMouseMove = (e: MouseEvent) => { + if (isDraggingLeftRef.current) { + const newWidth = Math.max(300, Math.min(750, e.clientX - 48)); + setLeftPanelWidth(newWidth); + } else if (isDraggingConsoleRef.current) { + const newHeight = Math.max(120, Math.min(550, window.innerHeight - e.clientY - 24)); + setBottomConsoleHeight(newHeight); + } else if (isDraggingAiRef.current) { + const newWidth = Math.max(280, Math.min(600, window.innerWidth - e.clientX)); + setAiPanelWidth(newWidth); + } + }; + + const handleMouseUp = () => { + isDraggingLeftRef.current = false; + isDraggingConsoleRef.current = false; + isDraggingAiRef.current = false; + }; + + window.addEventListener('mousemove', handleMouseMove); + window.addEventListener('mouseup', handleMouseUp); + + return () => { + window.removeEventListener('mousemove', handleMouseMove); + window.removeEventListener('mouseup', handleMouseUp); + }; + }, []); + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && (e.key.toLowerCase() === 'k' || (e.shiftKey && e.key.toLowerCase() === 'p'))) { + e.preventDefault(); + setIsCommandPaletteOpen(prev => !prev); + } else if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'b') { + e.preventDefault(); + setIsExplorerOpen(prev => !prev); + } else if (currentView === 'ide' && (e.metaKey || e.ctrlKey) && e.key === 'Enter') { + e.preventDefault(); + if (e.shiftKey) shortcutActionsRef.current.submit(); + else shortcutActionsRef.current.run(); + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [currentView]); + + const executionPayload = (testCaseId?: string, runTests = false) => ({ + problemId: currentProblem?.id || '', + language: currentLanguage, + files, + customInput, + testCaseId, + runTests + }); + + const applyExecutionResults = (result: ExecutionResult, fallbackStatus: TestCase['status'] = 'failed') => { + setTestCases((current) => current.map((testCase) => { + const resultCase = result.testResults?.find((item) => item.testCaseId === testCase.id); + if (!resultCase) return { ...testCase, status: fallbackStatus }; + return { + ...testCase, + status: resultCase.passed ? 'passed' : 'failed', + durationMs: resultCase.durationMs, + actualOutput: resultCase.actual + }; + })); + }; + + const handleRunCode = async () => { + const workContext = captureUserWorkContext(); + setConsoleTab('console'); + setConsoleOutput(prev => [...prev, '', `[Queued ${activeFile?.name || 'solution'} for execution...]`]); + try { + if (!currentProblem) throw new Error('Open a problem before running code.'); + const job = await execution.run('run', executionPayload()); + if (!isCurrentUserWork(workContext)) return; + const result = job.result; + if (!result) throw new Error(job.error || `Execution ${job.status.toLowerCase()}.`); + const output = [result.stdout, result.stderr ? `[STDERR] ${result.stderr}` : ''].filter(Boolean); + setConsoleOutput(prev => [...prev, ...output]); + applyExecutionResults(result, 'idle'); + } catch (error: unknown) { + if (!isCurrentUserWork(workContext)) return; + if (error instanceof DOMException && error.name === 'AbortError') return; + const message = error instanceof Error ? error.message : 'Unable to reach the execution service.'; + setConsoleOutput(prev => [...prev, `[ERROR] ${message}`]); + } + }; + + const handleSubmitCode = async () => { + const workContext = captureUserWorkContext(); + setConsoleTab('test-results'); + setTestCases((current) => current.map(tc => ({ ...tc, status: 'running' }))); + try { + if (!currentProblem) throw new Error('Open a problem before submitting code.'); + const job = await execution.run('submit', executionPayload()); + if (!isCurrentUserWork(workContext)) return; + const submission = job.submission; + if (!submission) throw new Error(job.error || `Submission ${job.status.toLowerCase()}.`); + const result = job.result || { + success: submission.status === 'ACCEPTED', + status: submission.status, + stdout: submission.consoleOutput?.join('\n') || '', + stderr: '', + durationMs: submission.durationMs, + testResults: submission.testResults + }; + applyExecutionResults(result); + setConsoleOutput((current) => [ + ...current, + '', + `[Submission ${submission.status}: ${submission.passedTests}/${submission.totalTests} tests passed]`, + ...(submission.consoleOutput || []) + ]); + if (String(submission.status).toUpperCase() === 'ACCEPTED') { + confetti({ particleCount: 100, spread: 70, origin: { y: 0.6 } }); + setSolvedProblemIds((prev) => prev.includes(currentProblem.id) ? prev : [...prev, currentProblem.id]); + } + } catch (error: unknown) { + if (!isCurrentUserWork(workContext)) return; + if (error instanceof DOMException && error.name === 'AbortError') return; + setTestCases((current) => current.map((testCase) => testCase.status === 'running' ? { ...testCase, status: 'failed' } : testCase)); + const message = error instanceof Error ? error.message : 'Sign in to submit your solution.'; + setConsoleOutput(prev => [...prev, `[ERROR] ${message}`]); + } + }; + + const handleRunSingleTest = async (testId: string) => { + const workContext = captureUserWorkContext(); + setTestCases(prev => prev.map(tc => tc.id === testId ? { ...tc, status: 'running' } : tc)); + try { + const job = await execution.run('run', executionPayload(testId)); + if (!isCurrentUserWork(workContext)) return; + const result = job.result; + if (!result) throw new Error(job.error || 'Execution failed'); + setTestCases(prev => prev.map(tc => { + if (tc.id !== testId) return tc; + const resultCase = result.testResults?.find((item) => item.testCaseId === tc.id); + if (!resultCase) return { ...tc, status: 'failed' }; + return { + ...tc, + status: resultCase.passed ? 'passed' : 'failed', + durationMs: resultCase.durationMs, + actualOutput: resultCase.actual + }; + })); + } catch { + if (!isCurrentUserWork(workContext)) return; + setTestCases(prev => prev.map(tc => tc.id === testId ? { ...tc, status: 'failed' } : tc)); + } + }; + + const handleRunAllTests = async () => { + const workContext = captureUserWorkContext(); + setConsoleTab('test-results'); + setTestCases((current) => current.map((testCase) => ({ ...testCase, status: 'running' }))); + try { + if (!currentProblem) throw new Error('Open a problem before running tests.'); + const job = await execution.run('run', executionPayload(undefined, true)); + if (!isCurrentUserWork(workContext)) return; + if (!job.result) throw new Error(job.error || 'Test execution failed.'); + applyExecutionResults(job.result); + setConsoleOutput((current) => [ + ...current, + '', + `[Public tests: ${job.result?.passedTests || 0}/${job.result?.totalTests || 0} passed]` + ]); + } catch (error: unknown) { + if (!isCurrentUserWork(workContext)) return; + if (error instanceof DOMException && error.name === 'AbortError') return; + setTestCases((current) => current.map((testCase) => + testCase.status === 'running' ? { ...testCase, status: 'failed' } : testCase + )); + const message = error instanceof Error ? error.message : 'Unable to run public tests.'; + setConsoleOutput((current) => [...current, `[ERROR] ${message}`]); + } + }; + + shortcutActionsRef.current = { + run: () => { void handleRunCode(); }, + submit: () => { void handleSubmitCode(); } + }; + + const handleSendAiMessage = async (prompt: string, category?: AiMessage['category']) => { + const workContext = captureUserWorkContext(); + aiAbortRef.current?.abort(); + const controller = new AbortController(); + aiAbortRef.current = controller; + const userMsg: AiMessage = { + id: `msg-${Date.now()}`, + role: 'user', + content: prompt, + timestamp: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }), + category + }; + + setAiMessages(prev => [...prev, userMsg]); + setIsAiLoading(true); + + try { + if (!currentProblem) { + setAiMessages(prev => [...prev, { + id: `ai-${Date.now()}`, + role: 'assistant', + content: 'Open a problem workspace first to get interview feedback.', + timestamp: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) + }]); + return; + } + const result = await api.sendAiMessage( + currentProblem.id, + prompt, + activeFile?.content || '', + { signal: controller.signal } + ); + if (!isCurrentUserWork(workContext) || controller.signal.aborted) return; + const response = result.success && result.data + ? result.data.content + : (result.error || 'AI interviewer is unavailable. No local mock responses are used.'); + + const aiMsg: AiMessage = { + id: `ai-${Date.now()}`, + role: 'assistant', + content: response, + timestamp: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) + }; + setAiMessages(prev => [...prev, aiMsg]); + } finally { + if (aiAbortRef.current === controller) { + aiAbortRef.current = null; + if (isCurrentUserWork(workContext)) setIsAiLoading(false); + } + } + }; + + const navigateTo = ( + view: AppView | string, + problem?: Problem, + pushHistory = true + ) => { + let targetView: AppView = 'auth'; + let path = '/login'; + + const normalizedView = view.toLowerCase(); + + if (normalizedView === 'landing') { + // Landing kept for optional marketing, but entry flow uses auth + targetView = 'landing'; + path = '/landing'; + } else if (normalizedView === 'home' || normalizedView === 'dashboard') { + targetView = currentUser?.isLoggedIn ? 'home' : 'auth'; + path = currentUser?.isLoggedIn ? '/home' : '/login'; + } else if (normalizedView === 'problems' || normalizedView === 'problemset') { + targetView = 'problems'; + path = '/problems'; + } else if (normalizedView.startsWith('problems/')) { + // Problem clicks open the workspace (statement + editor), not a separate details page + targetView = 'ide'; + const slug = normalizedView.replace('problems/', ''); + const found = problems.find((p) => p.id.toLowerCase() === slug) || problem; + if (found) setCurrentProblem(found); + path = `/workspace/${found ? found.id : slug}`; + } else if (normalizedView.startsWith('workspace/') || normalizedView.startsWith('problem/') || normalizedView === 'ide') { + targetView = 'ide'; + const p = problem || currentProblem; + if (problem) setCurrentProblem(problem); + if (!p) { + targetView = 'problems'; + path = '/problems'; + } else { + path = `/workspace/${p.id}`; + } + } else if (normalizedView === 'companies' || normalizedView.startsWith('companies/')) { + targetView = 'companies'; + path = normalizedView.startsWith('companies/') ? `/${normalizedView}` : '/companies'; + } else if ( + normalizedView === 'design-patterns' || + normalizedView.startsWith('design-patterns/') || + normalizedView === 'system-design' || + normalizedView.startsWith('system-design/') + ) { + targetView = 'design-patterns'; + const slug = normalizedView.includes('/') + ? normalizedView.split('/').slice(1).join('/') + : ''; + path = slug ? `/design-patterns/${slug}` : '/design-patterns'; + } else if (normalizedView === 'discuss' || normalizedView.startsWith('discuss/')) { + targetView = 'discuss'; + path = normalizedView.startsWith('discuss/') ? `/${normalizedView}` : '/discuss'; + } else if (normalizedView === 'profile' || normalizedView.startsWith('u/')) { + targetView = currentUser?.isLoggedIn ? 'profile' : 'auth'; + path = currentUser?.isLoggedIn + ? currentUser.name ? `/u/${encodeURIComponent(currentUser.name.toLowerCase())}` : '/profile' + : '/login'; + } else if (normalizedView === 'auth' || normalizedView === 'login' || normalizedView === 'signup') { + targetView = 'auth'; + path = normalizedView === 'signup' ? '/signup' : '/login'; + } else { + targetView = currentUser?.isLoggedIn ? 'home' : 'auth'; + path = currentUser?.isLoggedIn ? '/home' : '/login'; + } + + setCurrentView(targetView); + + if (pushHistory && window.location.pathname.toLowerCase() !== path.toLowerCase()) { + window.history.pushState({ view: targetView, problemId: problem?.id }, '', path); + } + }; + + useEffect(() => { + const handlePopState = () => { + const path = window.location.pathname.toLowerCase(); + if (path === '/' || path === '/home') { + setCurrentView(currentUser?.isLoggedIn ? 'home' : 'auth'); + } else if (path === '/landing') { + setCurrentView('landing'); + } else if (path === '/problems' || path === '/problemset') { + setCurrentView('problems'); + } else if (path.startsWith('/problems/')) { + const slug = path.replace('/problems/', ''); + const found = problems.find((p) => p.id.toLowerCase() === slug); + if (found) setCurrentProblem(found); + setCurrentView('ide'); + window.history.replaceState({ view: 'ide', problemId: found?.id }, '', `/workspace/${found ? found.id : slug}`); + } else if (path.startsWith('/workspace/') || path.startsWith('/problem/') || path === '/ide') { + const slug = path.replace('/workspace/', '').replace('/problem/', '').replace('/ide', ''); + if (slug) { + const found = problems.find((p) => p.id.toLowerCase() === slug); + if (found) setCurrentProblem(found); + } + setCurrentView('ide'); + } else if (path === '/companies' || path.startsWith('/companies/')) { + setCurrentView('companies'); + } else if ( + path === '/design-patterns' || + path.startsWith('/design-patterns/') || + path === '/system-design' || + path.startsWith('/system-design/') + ) { + setCurrentView('design-patterns'); + if (path.startsWith('/system-design')) { + window.history.replaceState({ view: 'design-patterns' }, '', '/design-patterns'); + } + } else if (path === '/discuss' || path.startsWith('/discuss/')) { + setCurrentView('discuss'); + } else if (path === '/profile' || path.startsWith('/u/')) { + setCurrentView(currentUser?.isLoggedIn ? 'profile' : 'auth'); + } else if (path === '/auth' || path === '/login' || path === '/signup') { + setCurrentView('auth'); + } + }; + + window.addEventListener('popstate', handlePopState); + return () => window.removeEventListener('popstate', handlePopState); + }, [problems, currentUser]); + + const handleOpenProblem = (problem: Problem) => { + setCurrentProblem(problem); + navigateTo('workspace/' + problem.id, problem); + }; + + const handleOpenWorkspace = (problem: Problem) => { + setCurrentProblem(problem); + navigateTo('workspace/' + problem.id, problem); + }; + + const handleUpdateProfile = (updatedUser: User) => { + session.updateLocalProfile(updatedUser); + }; + + const handleSignOut = () => { + void session.logout().then(() => { + const tokens = tokenStore.getSnapshot(); + if (!tokens.accessToken && !tokens.refreshToken) { + setCurrentView('auth'); + window.history.pushState({ view: 'auth' }, '', '/login'); + } + }); + }; + + if (session.status === 'loading') { + return ( +
+ Restoring your session… +
+ ); + } + + if (session.status === 'unavailable') { + return ( +
+

JudgeMyCode could not verify your saved session. Your credentials were preserved.

+ +
+ ); + } + + if (currentView === 'auth') { + const authPath = window.location.pathname.toLowerCase(); + return ( + { + session.clearOauthError(); + }} + /> + ); + } + + return ( +
+ {session.oauthError && ( +
+ {session.oauthError} + +
+ )} + {currentView === 'landing' && ( + navigateTo('problems')} + onOpenAuth={() => navigateTo('auth')} + onOpenPlayground={() => navigateTo('problems')} + onOpenSystemDesign={() => navigateTo('design-patterns')} + onOpenCompanies={() => navigateTo('companies')} + /> + )} + + {currentView === 'ide' && currentProblem && ( + { + setCurrentProblem(prob); + navigateTo('workspace/' + prob.id, prob); + }} + currentLanguage={currentLanguage} + onSelectLanguage={setCurrentLanguage} + theme={theme} + onToggleTheme={() => setTheme(theme === 'dark' ? 'light' : 'dark')} + fontSize={fontSize} + onChangeFontSize={setFontSize} + isRunning={isRunning} + onRunCode={handleRunCode} + onSubmitCode={handleSubmitCode} + isAiOpen={isAiOpen} + onToggleAi={() => setIsAiOpen(!isAiOpen)} + onOpenCommandPalette={() => setIsCommandPaletteOpen(true)} + onOpenAdminPortal={() => setIsAdminPortalOpen(true)} + currentUser={currentUser} + onOpenAuth={() => navigateTo('auth')} + onSignOut={handleSignOut} + onOpenHome={() => navigateTo('home')} + onOpenProblems={() => navigateTo('problems')} + onOpenProfile={() => navigateTo('profile')} + /> + )} + + {currentView === 'home' && ( + navigateTo('profile')} + onOpenAdmin={() => setIsAdminPortalOpen(true)} + onOpenProblem={handleOpenProblem} + onOpenCommandPalette={() => setIsCommandPaletteOpen(true)} + onOpenAuth={() => navigateTo('auth')} + onSignOut={handleSignOut} + onNavigate={(v) => navigateTo(v)} + /> + )} + + {currentView === 'problems' && ( + navigateTo(v)} + currentUser={currentUser} + onOpenProfile={() => navigateTo('profile')} + onOpenAdmin={() => setIsAdminPortalOpen(true)} + onOpenCommandPalette={() => setIsCommandPaletteOpen(true)} + onOpenAuth={() => navigateTo('auth')} + onSignOut={handleSignOut} + /> + )} + + {currentView === 'problem-details' && currentProblem && ( + navigateTo(v, p)} + /> + )} + + {currentView === 'problem-details' && !currentProblem && ( +
Problem not found.
+ )} + + {currentView === 'companies' && ( + navigateTo(v, p)} + currentUser={currentUser} + selectedCompany={window.location.pathname.match(/^\/companies\/([^/]+)/i)?.[1] || null} + /> + )} + + {currentView === 'design-patterns' && (() => { + const patternSlug = window.location.pathname.match(/^\/design-patterns\/([^/]+)/i)?.[1]; + if (!patternSlug) { + return ( + navigateTo(`design-patterns/${id}`)} + onNavigate={(v, p) => navigateTo(v, p)} + currentUser={currentUser} + onOpenCommandPalette={() => setIsCommandPaletteOpen(true)} + /> + ); + } + return ( + navigateTo(v, p)} + currentUser={currentUser} + onOpenCommandPalette={() => setIsCommandPaletteOpen(true)} + initialPatternId={patternSlug} + /> + ); + })()} + + {currentView === 'discuss' && ( + navigateTo(v, p)} + currentUser={currentUser} + /> + )} + + {currentView === 'profile' && ( + solvedProblemIds.includes(problem.id))} + onUpdateProfile={handleUpdateProfile} + onOpenHome={() => navigateTo('home')} + onOpenProblems={() => navigateTo('problems')} + onOpenProblem={handleOpenProblem} + onSignOut={handleSignOut} + onNavigate={(v) => navigateTo(v)} + /> + )} + + {currentView === 'ide' && !currentProblem && ( +
+ No problem loaded.{' '} + +
+ )} + + {currentView === 'ide' && currentProblem && ( + <> +
+ setIsProblemOpen(!isProblemOpen)} + isExplorerOpen={isExplorerOpen} + onToggleExplorer={() => setIsExplorerOpen(!isExplorerOpen)} + isAiOpen={isAiOpen} + onToggleAi={() => setIsAiOpen(!isAiOpen)} + onOpenShortcuts={() => setIsShortcutsModalOpen(true)} + currentUser={currentUser} + onOpenAuth={() => navigateTo('auth')} + onSignOut={handleSignOut} + /> + + {isProblemOpen ? ( + <> + setIsProblemOpen(false)} + /> +
{ isDraggingLeftRef.current = true; }} + className="split-resizer vertical" + title="Drag to resize problem panel" + /> + + ) : ( +
setIsProblemOpen(true)} + title="Click to expand Problem Statement" + className="w-8 bg-[#161b22] border-r border-[#30363d] flex flex-col items-center py-3 gap-3 select-none text-neutral-400 hover:text-white hover:bg-[#1c2128] cursor-pointer group transition-all shrink-0 z-10" + > + + + Problem Statement + +
+ )} + +
+
+ {isExplorerOpen && ( + setIsExplorerOpen(false)} + /> + )} + +
+ handleCreateFile('src', defaultFileName(currentLanguage, Date.now().toString().slice(-4)), false)} + isExplorerOpen={isExplorerOpen} + onToggleExplorer={() => setIsExplorerOpen(!isExplorerOpen)} + /> + +
+ + Loading editor… +
+ )} + > + setCursorPos({ line, col })} + /> + +
+
+
+ +
{ isDraggingConsoleRef.current = true; }} + className="split-resizer horizontal" + title="Drag to resize console" + /> + + setConsoleOutput(prev => [...prev, ...lines])} + testCases={testCases} + logs={logs} + diagnostics={diagnostics} + onClearConsole={() => setConsoleOutput([])} + onRunSingleTest={handleRunSingleTest} + onRunAllTests={handleRunAllTests} + isMaximized={isConsoleMaximized} + onToggleMaximize={() => setIsConsoleMaximized(!isConsoleMaximized)} + customInput={customInput} + onCustomInputChange={setCustomInput} + /> +
+ + {isAiOpen && ( + <> +
{ isDraggingAiRef.current = true; }} + className="split-resizer vertical" + title="Drag to resize AI panel" + /> + + setIsAiOpen(false)} + width={aiPanelWidth} + /> + + )} +
+ + diagnostic.severity === 'error').length} + warningCount={diagnostics.filter((diagnostic) => diagnostic.severity === 'warning').length} + onOpenShortcuts={() => setIsShortcutsModalOpen(true)} + /> + + setIsShortcutsModalOpen(false)} + /> + + {isAdminPortalOpen && currentUser?.role === 'admin' && ( + { + setCurrentProblem(problem); + setIsAdminPortalOpen(false); + navigateTo(`workspace/${problem.id}`, problem); + }} + onRefreshProblems={async () => { + const catalog = await api.getProblems(); + setProblems(catalog); + }} + onClose={() => setIsAdminPortalOpen(false)} + /> + )} + + )} + + setIsCommandPaletteOpen(false)} + onRunCode={handleRunCode} + onSubmitCode={handleSubmitCode} + onRunTests={handleRunAllTests} + onClearConsole={() => setConsoleOutput([])} + onToggleTheme={() => setTheme(theme === 'dark' ? 'light' : 'dark')} + onToggleAi={() => setIsAiOpen(!isAiOpen)} + problems={problems} + onSelectProblem={handleOpenProblem} + onSelectLanguage={setCurrentLanguage} + /> + + {currentView !== 'ide' && isAdminPortalOpen && currentUser?.role === 'admin' && ( + { + setCurrentProblem(problem); + setIsAdminPortalOpen(false); + navigateTo(`workspace/${problem.id}`, problem); + }} + onRefreshProblems={async () => { + const catalog = await api.getProblems(); + setProblems(catalog); + }} + onClose={() => setIsAdminPortalOpen(false)} + /> + )} +
+ ); +}; + +export default App; diff --git a/interviewhub-lld/src/components/AuthPage.tsx b/interviewhub-lld/src/components/AuthPage.tsx index 3845057c..2ac03629 100644 --- a/interviewhub-lld/src/components/AuthPage.tsx +++ b/interviewhub-lld/src/components/AuthPage.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { Code2, Settings, @@ -11,11 +11,10 @@ import { User as UserIcon, Check } from 'lucide-react'; -import { User } from '../types'; import { api } from '../services/apiClient'; interface AuthPageProps { - onSuccessLogin: (user: User) => void; + onSuccessLogin: () => void; initialMode?: 'signin' | 'signup'; initialErrorMessage?: string; } @@ -61,6 +60,10 @@ export const AuthPage: React.FC = ({ const [socialLoading, setSocialLoading] = useState<'github' | 'google' | 'linkedin' | null>(null); const [errorMessage, setErrorMessage] = useState(initialErrorMessage); + useEffect(() => { + setErrorMessage(initialErrorMessage); + }, [initialErrorMessage]); + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setErrorMessage(''); @@ -95,39 +98,21 @@ export const AuthPage: React.FC = ({ setErrorMessage(response.error || 'Unable to sign in. Please try again.'); return; } - const account = response.data.user; - onSuccessLogin({ - id: account.id, - name: account.name, - email: account.email, - avatar: account.avatar || `https://api.dicebear.com/7.x/bottts/svg?seed=${encodeURIComponent(account.name)}`, - role: account.role, - authProvider: account.authProvider, - isLoggedIn: true - }); + // The session-store event triggers /auth/me; that server response is the + // sole authority for identity and role. Never commit the login response's + // DTO directly into UI state because another tab may already have won. + onSuccessLogin(); } finally { setIsLoading(false); } }; - const handleSocialLogin = async (provider: 'github' | 'google' | 'linkedin') => { + const handleSocialLogin = (provider: 'github' | 'google' | 'linkedin') => { setErrorMessage(''); setSocialLoading(provider); - try { - const res = await api.fetchOAuthAuthUrl(provider); - if (res.success && res.data?.url) { - window.location.href = res.data.url; - return; - } - if (res.error) { - setErrorMessage(res.error); - setSocialLoading(null); - return; - } - window.location.href = api.getOAuthUrl(provider); - } catch { - window.location.href = api.getOAuthUrl(provider); - } + // Start at the API as a top-level navigation so its HttpOnly OAuth-state + // cookie is first-party and can bind the provider callback to this browser. + window.location.assign(api.getOAuthUrl(provider)); }; const switchMode = (nextMode: 'signin' | 'signup') => { diff --git a/interviewhub-lld/src/components/BottomConsole.tsx b/interviewhub-lld/src/components/BottomConsole.tsx index 879fffe6..1e476d18 100644 --- a/interviewhub-lld/src/components/BottomConsole.tsx +++ b/interviewhub-lld/src/components/BottomConsole.tsx @@ -32,6 +32,8 @@ interface BottomConsoleProps { onRunAllTests?: () => void; isMaximized?: boolean; onToggleMaximize?: () => void; + customInput?: string; + onCustomInputChange?: (value: string) => void; } export const BottomConsole: React.FC = ({ @@ -49,10 +51,11 @@ export const BottomConsole: React.FC = ({ onRunSingleTest = () => {}, onRunAllTests = () => {}, isMaximized = false, - onToggleMaximize = () => {} + onToggleMaximize = () => {}, + customInput = '', + onCustomInputChange = () => {} }) => { const [copied, setCopied] = useState(false); - const [stdinInput, setStdinInput] = useState(''); const consoleEndRef = useRef(null); const lines = Array.isArray(consoleOutput) ? consoleOutput : Array.isArray(output) ? output : []; @@ -78,9 +81,6 @@ export const BottomConsole: React.FC = ({ const handleStdinSubmit = (e: React.FormEvent) => { e.preventDefault(); - if (!stdinInput.trim()) return; - onAppendOutput([`> ${stdinInput}`, `[Executed]: ${stdinInput}`]); - setStdinInput(''); }; const passedTestsCount = safeTestCases.filter(t => t.status === 'passed').length; @@ -189,7 +189,7 @@ export const BottomConsole: React.FC = ({ {activeTab === 'console' && (
{lines.length === 0 ? ( -
Console ready. Click "Run Code" or press Ctrl+R to execute.
+
Console ready. Click "Run Code" or press Cmd/Ctrl+Enter to execute.
) : ( lines.map((line, idx) => (
= ({ > setStdinInput(e.target.value)} + placeholder="Optional stdin for the next Run Code execution..." + value={customInput} + onChange={(e) => onCustomInputChange(e.target.value)} className="flex-1 bg-transparent text-neutral-200 outline-none text-xs font-mono placeholder:text-neutral-600" /> diff --git a/interviewhub-lld/src/components/ProfilePage.tsx b/interviewhub-lld/src/components/ProfilePage.tsx index 2f71d041..5ed0c702 100644 --- a/interviewhub-lld/src/components/ProfilePage.tsx +++ b/interviewhub-lld/src/components/ProfilePage.tsx @@ -53,6 +53,7 @@ interface SubmissionRow { id: string; problemId: string; problemTitle?: string; + evaluationRevision?: string; status: string; language: string; durationMs?: number; diff --git a/interviewhub-lld/src/components/StatusBar.tsx b/interviewhub-lld/src/components/StatusBar.tsx index 8da606fc..addf0b26 100644 --- a/interviewhub-lld/src/components/StatusBar.tsx +++ b/interviewhub-lld/src/components/StatusBar.tsx @@ -1,12 +1,19 @@ import React from 'react'; -import { CheckCircle, Keyboard, Cpu, Code2 } from 'lucide-react'; +import { Keyboard } from 'lucide-react'; import { Language } from '../types'; +import type { WorkspaceSaveStatus } from '../features/workspace/hooks/useWorkspace'; interface StatusBarProps { cursorLine: number; cursorCol: number; language: Language; - isSaving: boolean; + saveStatus: WorkspaceSaveStatus; + saveError?: string; + hasConflict?: boolean; + onKeepLocalVersion?: () => void; + onUseServerVersion?: () => void; + errorCount?: number; + warningCount?: number; onOpenShortcuts: () => void; } @@ -14,7 +21,13 @@ export const StatusBar: React.FC = ({ cursorLine, cursorCol, language, - isSaving, + saveStatus, + saveError, + hasConflict = false, + onKeepLocalVersion, + onUseServerVersion, + errorCount = 0, + warningCount = 0, onOpenShortcuts }) => { return ( @@ -22,20 +35,31 @@ export const StatusBar: React.FC = ({ {/* Left items */}
{/* Autosave */} -
- - {isSaving ? 'Saving...' : 'Autosaved'} +
+ + {saveStatus === 'loading' ? 'Loading draft...' : saveStatus === 'saving' ? 'Saving...' : saveStatus === 'saved' ? 'Saved' : saveStatus === 'error' ? 'Local draft only' : 'Ready'}
- {/* Git Branch mock */} -
- main* -
+ {hasConflict && ( +
+ Draft conflict: + + / + +
+ )} - {/* Diagnostics mock */} + {/* Diagnostics reported by the editor/compiler integration. */}
- ✓ 0 Errors - ⚠ 0 Warnings + 0 ? 'text-rose-400' : 'text-emerald-400'}> + {errorCount > 0 ? '✕' : '✓'} {errorCount} {errorCount === 1 ? 'Error' : 'Errors'} + + 0 ? 'text-amber-400' : 'text-neutral-500'}> + ⚠ {warningCount} {warningCount === 1 ? 'Warning' : 'Warnings'} +
diff --git a/interviewhub-lld/src/components/TopNav.tsx b/interviewhub-lld/src/components/TopNav.tsx index 2d16c7b1..85281933 100644 --- a/interviewhub-lld/src/components/TopNav.tsx +++ b/interviewhub-lld/src/components/TopNav.tsx @@ -269,7 +269,8 @@ export const TopNav: React.FC = ({
- {formStarterFiles.map(node => ( + {flattenSourceFiles(formStarterFiles).map(node => (
= ({ } return ''; }; - return findContent(formStarterFiles) || (formStarterFiles[0]?.content || ''); + return findContent(formStarterFiles) || (flattenSourceFiles(formStarterFiles)[0]?.content || ''); })()} onChange={e => { const newText = e.target.value; @@ -688,7 +732,7 @@ export const AdminPortal: React.FC = ({ return n; }); }; - setFormStarterFiles(updateContent(formStarterFiles)); + setFormStarterFiles((current) => updateContent(current)); }} rows={18} spellCheck={false} @@ -731,11 +775,7 @@ export const AdminPortal: React.FC = ({ className="form-input flex-1" placeholder="Test Name" value={tc.name} - onChange={e => { - const updated = [...formTestCases]; - updated[idx].name = e.target.value; - setFormTestCases(updated); - }} + onChange={e => setFormTestCases((current) => current.map((item, index) => index === idx ? { ...item, name: e.target.value } : item))} />
@@ -765,11 +801,7 @@ export const AdminPortal: React.FC = ({ type="text" className="form-input" value={tc.expectedOutput} - onChange={e => { - const updated = [...formTestCases]; - updated[idx].expectedOutput = e.target.value; - setFormTestCases(updated); - }} + onChange={e => setFormTestCases((current) => current.map((item, index) => index === idx ? { ...item, expectedOutput: e.target.value } : item))} />
@@ -819,6 +851,11 @@ export const AdminPortal: React.FC = ({ setFormTitle(parsed.title || formTitle); setFormDifficulty(parsed.difficulty || formDifficulty); setFormDescription(parsed.description || formDescription); + if (parsed.starterFiles && typeof parsed.starterFiles === 'object') { + const starterFiles = cloneStarterFiles(parsed.starterFiles); + setFormStarterFilesByLanguage(starterFiles); + setSelectedBoilerplateFile(flattenSourceFiles(starterFiles[selectedBoilerplateLanguage])[0]?.path || ''); + } } catch (_) {} }} /> diff --git a/interviewhub-lld/src/features/admin/api.ts b/interviewhub-lld/src/features/admin/api.ts new file mode 100644 index 00000000..0b7c5a9b --- /dev/null +++ b/interviewhub-lld/src/features/admin/api.ts @@ -0,0 +1,37 @@ +import { httpClient } from '../../shared/api'; +import type { ApiRequestOptions } from '../../shared/api'; +import type { Problem } from '../../types'; + +export type ProblemUpdate = Partial & { updatedAt: string }; + +export const adminProblemsApi = { + create(problem: Partial, options: ApiRequestOptions = {}) { + return httpClient.request('/admin/problems', { + ...options, + method: 'POST', + body: JSON.stringify(problem) + }); + }, + + update(id: string, updates: ProblemUpdate, options: ApiRequestOptions = {}) { + return httpClient.request(`/admin/problems/${encodeURIComponent(id)}`, { + ...options, + method: 'PUT', + body: JSON.stringify(updates) + }); + }, + + remove(id: string, options: ApiRequestOptions = {}) { + return httpClient.request<{ id: string }>(`/admin/problems/${encodeURIComponent(id)}`, { + ...options, + method: 'DELETE' + }); + }, + + reset(options: ApiRequestOptions = {}) { + return httpClient.request('/admin/problems/reset/defaults', { + ...options, + method: 'POST' + }); + } +}; diff --git a/interviewhub-lld/src/features/ai/api.ts b/interviewhub-lld/src/features/ai/api.ts new file mode 100644 index 00000000..361c6d32 --- /dev/null +++ b/interviewhub-lld/src/features/ai/api.ts @@ -0,0 +1,13 @@ +import { httpClient } from '../../shared/api'; +import type { ApiRequestOptions } from '../../shared/api'; +import type { AiMessage } from '../../types'; + +export const aiApi = { + send(problemId: string, prompt: string, codeSnippet = '', options: ApiRequestOptions = {}) { + return httpClient.request('/ai/chat', { + ...options, + method: 'POST', + body: JSON.stringify({ problemId, prompt, codeSnippet }) + }); + } +}; diff --git a/interviewhub-lld/src/features/auth/api.ts b/interviewhub-lld/src/features/auth/api.ts new file mode 100644 index 00000000..702c02cc --- /dev/null +++ b/interviewhub-lld/src/features/auth/api.ts @@ -0,0 +1,146 @@ +import { httpClient, tokenStore } from '../../shared/api'; +import type { ApiRequestOptions, ApiResult } from '../../shared/api'; + +export type AuthProvider = 'google' | 'github' | 'linkedin'; + +export interface AuthAccount { + id: string; + name: string; + email: string; + avatar?: string; + role: 'candidate' | 'admin'; + authProvider?: string; + createdAt?: string; +} + +export interface AuthSessionDto { + user: AuthAccount; + token: string; + refreshToken?: string; +} + +const persistSession = async (result: ApiResult): Promise> => { + if (result.success && result.data?.token) { + try { + await tokenStore.replaceSession(result.data.token, result.data.refreshToken || null); + } catch (error: unknown) { + return { + success: false, + status: 0, + error: error instanceof Error ? error.message : 'Unable to persist the authenticated session' + }; + } + } + return result; +}; + +const revokePendingLogoutsUnlocked = async (options: ApiRequestOptions = {}): Promise> => { + let lastResult: ApiResult = { success: true, status: 204 }; + // Re-read after every revocation so a logout queued by another tab while the + // lock is held cannot be stranded by a coalesced storage event. + for (let processed = 0; processed < 256; processed += 1) { + const refreshToken = tokenStore.getPendingLogoutToken(); + if (!refreshToken) return lastResult; + lastResult = await httpClient.request('/auth/logout', { + ...options, + method: 'POST', + body: JSON.stringify({ refreshToken }), + retryUnauthorized: false + }); + if (!lastResult.success) return lastResult; + await tokenStore.completeLogout(refreshToken); + } + return { + success: false, + status: 0, + error: 'The pending logout queue changed too quickly to drain safely' + }; +}; + +let pendingLogoutInFlight: Promise> | null = null; + +const retryPendingLogout = (options: ApiRequestOptions = {}): Promise> => { + if (pendingLogoutInFlight) return pendingLogoutInFlight; + pendingLogoutInFlight = tokenStore.withLogoutDrainLock(() => revokePendingLogoutsUnlocked(options)) + .catch((error: unknown) => ({ + success: false, + status: 0, + error: error instanceof Error ? error.message : 'Unable to revoke the pending sessions' + } satisfies ApiResult)) + .finally(() => { + pendingLogoutInFlight = null; + }); + return pendingLogoutInFlight; +}; + +const authenticate = async ( + request: () => Promise> +): Promise> => { + // Per-credential queue records survive account replacement, so authentication + // never waits on old network revocations. Cleanup runs independently after + // the new session is persisted. + const result = await persistSession(await request()); + if (result.success) void retryPendingLogout(); + return result; +}; + +export const authApi = { + getOAuthUrl(provider: AuthProvider): string { + return `${httpClient.baseUrl}/auth/oauth/${provider}`; + }, + + async exchangeOAuthCode(code: string, options: ApiRequestOptions = {}) { + return authenticate(() => httpClient.request('/auth/exchange', { + ...options, + method: 'POST', + body: JSON.stringify({ code }), + retryUnauthorized: false + })); + }, + + async login(email: string, password: string, options: ApiRequestOptions = {}) { + return authenticate(() => httpClient.request('/auth/login', { + ...options, + method: 'POST', + body: JSON.stringify({ email, password }), + retryUnauthorized: false + })); + }, + + async register(name: string, email: string, password: string, options: ApiRequestOptions = {}) { + return authenticate(() => httpClient.request('/auth/register', { + ...options, + method: 'POST', + body: JSON.stringify({ name, email, password }), + retryUnauthorized: false + })); + }, + + getMe(options: ApiRequestOptions = {}) { + return httpClient.request('/auth/me', options); + }, + + async refreshSession(options: ApiRequestOptions = {}) { + return httpClient.refreshSession(options); + }, + + async logout(options: ApiRequestOptions = {}) { + try { + // Queue and clear synchronously. Any in-flight refresh is then unable to + // pass its exact-lineage compare-and-swap, so it cannot resurrect this + // session while we wait for the cross-tab network lock. + await tokenStore.beginLogout(); + } catch (error: unknown) { + return { + success: false, + status: 0, + error: error instanceof Error ? error.message : 'Unable to preserve the session for revocation' + } satisfies ApiResult; + } + return retryPendingLogout(options); + }, + + async retryPendingLogout(options: ApiRequestOptions = {}) { + return retryPendingLogout(options); + } +}; diff --git a/interviewhub-lld/src/features/auth/useSession.ts b/interviewhub-lld/src/features/auth/useSession.ts new file mode 100644 index 00000000..1e7bed4d --- /dev/null +++ b/interviewhub-lld/src/features/auth/useSession.ts @@ -0,0 +1,246 @@ +import { useCallback, useEffect, useState } from 'react'; +import type { User } from '../../types'; +import { tokenStore } from '../../shared/api'; +import type { ApiResult, TokenSnapshot } from '../../shared/api'; +import { authApi, type AuthAccount } from './api'; + +export type SessionStatus = 'loading' | 'authenticated' | 'anonymous' | 'unavailable'; + +interface BootstrapResult { + user: User | null; + error: string; + retryable?: boolean; + superseded?: boolean; +} + +let bootstrapInFlight: { key: string; promise: Promise } | null = null; + +const tokenFingerprint = (tokens = tokenStore.getSnapshot()): string => { + return `${tokens.accessToken || ''}\u0000${tokens.refreshToken || ''}`; +}; + +const hasOAuthHandoff = (): boolean => { + const query = new URLSearchParams(window.location.search); + const fragment = new URLSearchParams(window.location.hash.replace(/^#/, '')); + return Boolean( + query.get('oauth_code') || fragment.get('oauth_code') || + query.get('oauth_error') || fragment.get('oauth_error') + ); +}; + +const toUser = (account: AuthAccount): User => ({ + id: account.id, + name: account.name, + email: account.email, + avatar: account.avatar || `https://api.dicebear.com/7.x/bottts/svg?seed=${encodeURIComponent(account.name)}`, + role: account.role, + authProvider: account.authProvider, + isLoggedIn: true +}); + +const removeOAuthQuery = (): void => { + const params = new URLSearchParams(window.location.search); + const fragment = new URLSearchParams(window.location.hash.replace(/^#/, '')); + params.delete('oauth_code'); + params.delete('oauth_error'); + fragment.delete('oauth_code'); + fragment.delete('oauth_error'); + const search = params.toString(); + const hash = fragment.toString(); + window.history.replaceState({}, document.title, `${window.location.pathname}${search ? `?${search}` : ''}${hash ? `#${hash}` : ''}`); +}; + +const clearStoredSession = async (expected: TokenSnapshot): Promise<{ error: string; superseded: boolean }> => { + if (!expected.accessToken && !expected.refreshToken) return { error: '', superseded: false }; + try { + const cleared = await tokenStore.clearIfUnchanged(expected); + return { error: '', superseded: !cleared }; + } catch (error: unknown) { + return { + error: error instanceof Error ? error.message : 'Unable to clear the browser session safely', + superseded: false + }; + } +}; + +const bootstrap = (): Promise => { + const bootstrapParams = new URLSearchParams(window.location.search); + const bootstrapFragment = new URLSearchParams(window.location.hash.replace(/^#/, '')); + const pendingOAuthCode = bootstrapParams.get('oauth_code') || bootstrapFragment.get('oauth_code') || ''; + // Token persistence emits synchronously during a successful exchange. Keep + // every observer on the same one-time exchange promise until its URL handoff + // has been removed, regardless of the newly written token fingerprint. + const key = pendingOAuthCode + ? `oauth:${pendingOAuthCode}` + : `${window.location.search}\u0000${window.location.hash}\u0000${tokenFingerprint()}`; + if (bootstrapInFlight?.key === key) return bootstrapInFlight.promise; + + const promise = (async () => { + // This legacy object previously controlled authorization UI. The server is + // now the only authority for identity and role. + try { + window.localStorage.removeItem('judgemycode_user'); + } catch { + // Storage may be unavailable; session tokens are handled independently. + } + + const params = new URLSearchParams(window.location.search); + const fragment = new URLSearchParams(window.location.hash.replace(/^#/, '')); + const oauthError = params.get('oauth_error') || fragment.get('oauth_error') || ''; + const oauthCode = params.get('oauth_code') || fragment.get('oauth_code') || ''; + + if (oauthError) { + const sessionToClear = tokenStore.getSnapshot(); + removeOAuthQuery(); + const clearResult = await clearStoredSession(sessionToClear); + if (clearResult.superseded) return { user: null, error: '', superseded: true }; + if (clearResult.error) return { user: null, error: clearResult.error, retryable: true }; + return { user: null, error: oauthError }; + } + + if (oauthCode) { + const sessionBeforeExchange = tokenStore.getSnapshot(); + const exchange = await authApi.exchangeOAuthCode(oauthCode); + if (!exchange.success) { + if (exchange.status === 400 || exchange.status === 401) { + removeOAuthQuery(); + const clearResult = await clearStoredSession(sessionBeforeExchange); + if (clearResult.superseded) return { user: null, error: '', superseded: true }; + if (clearResult.error) return { user: null, error: clearResult.error, retryable: true }; + } + return { + user: null, + error: exchange.error || 'OAuth sign-in failed', + retryable: exchange.status === 0 || exchange.status === 429 || exchange.status >= 500 + }; + } + removeOAuthQuery(); + } + + const tokens = tokenStore.getSnapshot(); + if (!tokens.accessToken && !tokens.refreshToken) return { user: null, error: '' }; + + const response = await authApi.getMe(); + if (!response.success || !response.data) { + if (response.status === 400 || response.status === 401) { + const clearResult = await clearStoredSession(tokens); + if (clearResult.superseded) return { user: null, error: '', superseded: true }; + if (clearResult.error) return { user: null, error: clearResult.error, retryable: true }; + return { user: null, error: '' }; + } + return { + user: null, + error: response.error || 'Unable to verify your saved session', + retryable: true + }; + } + return { user: toUser(response.data), error: '' }; + })().finally(() => { + if (bootstrapInFlight?.promise === promise) bootstrapInFlight = null; + }); + + bootstrapInFlight = { key, promise }; + return promise; +}; + +export interface SessionController { + user: User | null; + status: SessionStatus; + principalId: string | null; + sessionEpoch: string | null; + oauthError: string; + clearOauthError: () => void; + updateLocalProfile: (user: User) => void; + logout: () => Promise>; +} + +export const useSession = (): SessionController => { + const [user, setUser] = useState(null); + const [status, setStatus] = useState('loading'); + const [principalId, setPrincipalId] = useState(() => tokenStore.getSnapshot().principalId); + const [sessionEpoch, setSessionEpoch] = useState(() => tokenStore.getSnapshot().sessionEpoch); + const [oauthError, setOauthError] = useState(''); + + useEffect(() => { + let active = true; + let generation = 0; + + const synchronize = (tokens = tokenStore.getSnapshot()) => { + const expectedFingerprint = tokenFingerprint(tokens); + const currentGeneration = ++generation; + setPrincipalId(tokens.principalId); + setSessionEpoch(tokens.sessionEpoch); + if (!tokens.accessToken && !tokens.refreshToken && !hasOAuthHandoff()) { + setUser(null); + setStatus('anonymous'); + return; + } + // Suspend every authenticated mutation immediately when credentials + // change. Until /auth/me validates this exact fingerprint, retaining the + // previous user would mix that user's local workspace key with another + // tab's bearer token. + setUser(null); + setStatus('loading'); + void bootstrap().then((result) => { + if (!active || currentGeneration !== generation) return; + // A login/refresh in this or another tab superseded the request. Its + // storage event owns the next authoritative /auth/me validation. + if (tokenFingerprint() !== expectedFingerprint) return; + if (result.superseded) return; + setUser(result.user); + setOauthError(result.error); + setStatus(result.user ? 'authenticated' : result.retryable ? 'unavailable' : 'anonymous'); + }); + }; + + const retryPendingRevocations = () => { void authApi.retryPendingLogout(); }; + retryPendingRevocations(); + synchronize(tokenStore.getSnapshot()); + const unsubscribe = tokenStore.subscribe((tokens) => synchronize(tokens)); + const unsubscribePending = tokenStore.subscribePendingLogouts(retryPendingRevocations); + window.addEventListener('online', retryPendingRevocations); + + return () => { + active = false; + unsubscribe(); + unsubscribePending(); + window.removeEventListener('online', retryPendingRevocations); + }; + }, []); + + const updateLocalProfile = useCallback((nextUser: User) => { + setUser((current) => current ? { + ...current, + name: nextUser.name, + avatar: nextUser.avatar, + isLoggedIn: true, + role: current.role + } : current); + }, []); + + const logout = useCallback(async (): Promise> => { + // The locked beginLogout transition drives UI state. If durable queueing + // fails, the current authenticated session remains visible and usable. + const result = await authApi.logout(); + const tokens = tokenStore.getSnapshot(); + if (!result.success) { + setOauthError(tokens.accessToken || tokens.refreshToken + ? result.error || 'Unable to sign out safely' + : 'Signed out locally. Server-side revocation is pending and will retry automatically.'); + } else { + setOauthError(''); + } + return result; + }, []); + + return { + user, + status, + principalId, + sessionEpoch, + oauthError, + clearOauthError: () => setOauthError(''), + updateLocalProfile, + logout + }; +}; diff --git a/interviewhub-lld/src/features/execution/api.ts b/interviewhub-lld/src/features/execution/api.ts new file mode 100644 index 00000000..23cb809e --- /dev/null +++ b/interviewhub-lld/src/features/execution/api.ts @@ -0,0 +1,144 @@ +import { httpClient } from '../../shared/api'; +import type { ApiRequestOptions } from '../../shared/api'; +import type { FileNode, Language } from '../../types'; +import type { Submission } from '../submissions/api'; + +export type ExecutionKind = 'run' | 'submit'; +export type ExecutionJobStatus = 'QUEUED' | 'RUNNING' | 'COMPLETED' | 'FAILED' | 'TIMED_OUT'; + +export interface ExecutionPayload { + problemId: string; + language: Language; + files: FileNode[]; + customInput?: string; + testCaseId?: string; + runTests?: boolean; +} + +export interface ExecutionTestResult { + testCaseId: string; + name: string; + passed: boolean; + durationMs: number; + expected: string; + actual: string; + error?: string; +} + +export interface ExecutionResult { + success: boolean; + status: string; + stdout: string; + stderr: string; + durationMs: number; + testResults?: ExecutionTestResult[]; + passedTests?: number; + totalTests?: number; +} + +export interface ExecutionReceipt { + executionId: string; + status: ExecutionJobStatus; + kind: ExecutionKind; +} + +export interface ExecutionJob extends ExecutionReceipt { + result?: ExecutionResult; + submission?: Submission; + error?: string; + createdAt?: string; + updatedAt?: string; +} + +export interface PollExecutionOptions { + signal?: AbortSignal; + timeoutMs?: number; + intervalMs?: number; +} + +const TERMINAL_STATES = new Set(['COMPLETED', 'FAILED', 'TIMED_OUT']); + +const wait = (milliseconds: number, signal?: AbortSignal): Promise => { + return new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(new DOMException('Execution polling cancelled', 'AbortError')); + return; + } + const finish = () => { + signal?.removeEventListener('abort', abort); + resolve(); + }; + const timeoutId = window.setTimeout(finish, milliseconds); + const abort = () => { + window.clearTimeout(timeoutId); + signal?.removeEventListener('abort', abort); + reject(new DOMException('Execution polling cancelled', 'AbortError')); + }; + signal?.addEventListener('abort', abort, { once: true }); + }); +}; + +const enqueue = (kind: ExecutionKind, payload: ExecutionPayload, options: ApiRequestOptions = {}) => { + return httpClient.request(`/execute/${kind === 'run' ? 'run' : 'submit'}`, { + ...options, + method: 'POST', + body: JSON.stringify(payload) + }); +}; + +export const executionApi = { + enqueueRun(payload: ExecutionPayload, options: ApiRequestOptions = {}) { + return enqueue('run', payload, options); + }, + + enqueueSubmit(payload: ExecutionPayload, options: ApiRequestOptions = {}) { + return enqueue('submit', payload, options); + }, + + getJob(executionId: string, options: ApiRequestOptions = {}) { + return httpClient.request(`/executions/${encodeURIComponent(executionId)}`, options); + }, + + async waitForCompletion(receipt: ExecutionReceipt, options: PollExecutionOptions = {}): Promise { + // The bounded server queue may legitimately wait behind other users; keep + // polling longer than the default queue's worst normal development burst. + const timeoutMs = options.timeoutMs ?? 180_000; + const intervalMs = options.intervalMs ?? 350; + const deadline = Date.now() + timeoutMs; + + while (Date.now() < deadline) { + if (options.signal?.aborted) { + throw new DOMException('Execution polling cancelled', 'AbortError'); + } + + const response = await this.getJob(receipt.executionId, { + signal: options.signal, + timeoutMs: Math.min(10_000, Math.max(1_000, deadline - Date.now())) + }); + if (!response.success || !response.data) { + if (options.signal?.aborted) { + throw new DOMException('Execution polling cancelled', 'AbortError'); + } + throw new Error(response.error || 'Unable to read execution status'); + } + if (TERMINAL_STATES.has(response.data.status)) return response.data; + await wait(Math.min(intervalMs, Math.max(0, deadline - Date.now())), options.signal); + } + + throw new Error(`Execution did not finish within ${Math.round(timeoutMs / 1000)} seconds`); + }, + + async runAndWait(kind: ExecutionKind, payload: ExecutionPayload, options: PollExecutionOptions = {}) { + const queued = await enqueue(kind, payload, { + signal: options.signal, + timeoutMs: options.timeoutMs + }); + if (!queued.success || !queued.data) { + if (options.signal?.aborted) { + throw new DOMException('Execution polling cancelled', 'AbortError'); + } + throw new Error(queued.error || `Unable to queue ${kind}`); + } + return this.waitForCompletion(queued.data, options); + } +}; diff --git a/interviewhub-lld/src/features/execution/hooks/useExecutionJob.ts b/interviewhub-lld/src/features/execution/hooks/useExecutionJob.ts new file mode 100644 index 00000000..d1c0a132 --- /dev/null +++ b/interviewhub-lld/src/features/execution/hooks/useExecutionJob.ts @@ -0,0 +1,52 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { + executionApi, + type ExecutionJob, + type ExecutionKind, + type ExecutionPayload +} from '../api'; + +export interface ExecutionController { + isRunning: boolean; + activeKind: ExecutionKind | null; + run: (kind: ExecutionKind, payload: ExecutionPayload) => Promise; + cancel: () => void; +} + +/** Owns the lifecycle of one queued execution and cancels stale work on unmount. */ +export const useExecutionJob = (): ExecutionController => { + const [isRunning, setIsRunning] = useState(false); + const [activeKind, setActiveKind] = useState(null); + const controllerRef = useRef(null); + + const cancel = useCallback(() => { + controllerRef.current?.abort(); + controllerRef.current = null; + setIsRunning(false); + setActiveKind(null); + }, []); + + useEffect(() => cancel, [cancel]); + + const run = useCallback(async (kind: ExecutionKind, payload: ExecutionPayload) => { + if (controllerRef.current) { + throw new Error('Wait for the active execution to finish before starting another.'); + } + const controller = new AbortController(); + controllerRef.current = controller; + setIsRunning(true); + setActiveKind(kind); + + try { + return await executionApi.runAndWait(kind, payload, { signal: controller.signal }); + } finally { + if (controllerRef.current === controller) { + controllerRef.current = null; + setIsRunning(false); + setActiveKind(null); + } + } + }, []); + + return { isRunning, activeKind, run, cancel }; +}; diff --git a/interviewhub-lld/src/features/problems/api.ts b/interviewhub-lld/src/features/problems/api.ts new file mode 100644 index 00000000..0a96eedf --- /dev/null +++ b/interviewhub-lld/src/features/problems/api.ts @@ -0,0 +1,14 @@ +import { httpClient } from '../../shared/api'; +import type { ApiRequestOptions } from '../../shared/api'; +import type { Problem } from '../../types'; + +export const problemsApi = { + async list(options: ApiRequestOptions = {}): Promise { + const result = await httpClient.request('/problems', options); + return result.success && Array.isArray(result.data) ? result.data : []; + }, + + get(id: string, options: ApiRequestOptions = {}) { + return httpClient.request(`/problems/${encodeURIComponent(id)}`, options); + } +}; diff --git a/interviewhub-lld/src/features/submissions/api.ts b/interviewhub-lld/src/features/submissions/api.ts new file mode 100644 index 00000000..7e0c8042 --- /dev/null +++ b/interviewhub-lld/src/features/submissions/api.ts @@ -0,0 +1,64 @@ +import { httpClient } from '../../shared/api'; +import type { ApiRequestOptions } from '../../shared/api'; +import type { FileNode, Language } from '../../types'; +import type { ExecutionTestResult } from '../execution/api'; + +export interface Submission { + id: string; + userId: string; + userEmail?: string; + problemId: string; + problemTitle?: string; + evaluationRevision?: string; + language: Language; + files?: FileNode[]; + status: string; + passedTests: number; + totalTests: number; + score: number; + durationMs: number; + testResults?: ExecutionTestResult[]; + consoleOutput?: string[]; + createdAt: string; +} + +export interface UserProgress { + solvedCount: number; + submissionCount: number; + acceptedCount: number; + currentStreakDays: number; + solvedProblemIds: string[]; +} + +export interface SubmissionPage { + items: Submission[]; + nextCursor?: string; +} + +export interface SubmissionPageOptions { + cursor?: string; + limit?: number; +} + +export const submissionsApi = { + list( + problemId?: string, + page: SubmissionPageOptions = {}, + options: ApiRequestOptions = {} + ) { + const query = new URLSearchParams(); + if (problemId) query.set('problemId', problemId); + if (page.cursor) query.set('cursor', page.cursor); + if (page.limit !== undefined) query.set('limit', String(page.limit)); + const suffix = query.size > 0 ? `?${query.toString()}` : ''; + return httpClient.request(`/submissions${suffix}`, options); + }, + + get(id: string, options: ApiRequestOptions = {}) { + return httpClient.request(`/submissions/${encodeURIComponent(id)}`, options); + }, + + getProgress(options: ApiRequestOptions = {}) { + return httpClient.request('/users/me/progress', options); + } +}; diff --git a/interviewhub-lld/src/features/workspace/api.ts b/interviewhub-lld/src/features/workspace/api.ts new file mode 100644 index 00000000..4674e7ce --- /dev/null +++ b/interviewhub-lld/src/features/workspace/api.ts @@ -0,0 +1,34 @@ +import { httpClient } from '../../shared/api'; +import type { ApiRequestOptions } from '../../shared/api'; +import type { FileNode, Language } from '../../types'; + +export interface WorkspaceDto { + userId?: string; + problemId: string; + language: Language; + files: FileNode[]; + activeFileId?: string; + revision: number; + updatedAt?: string; +} + +export const workspaceApi = { + get(problemId: string, language: Language, options: ApiRequestOptions = {}) { + return httpClient.request( + `/problems/${encodeURIComponent(problemId)}/workspace?language=${encodeURIComponent(language)}`, + options + ); + }, + + save( + problemId: string, + workspace: Pick, + options: ApiRequestOptions = {} + ) { + return httpClient.request(`/problems/${encodeURIComponent(problemId)}/workspace`, { + ...options, + method: 'PUT', + body: JSON.stringify(workspace) + }); + } +}; diff --git a/interviewhub-lld/src/features/workspace/hooks/useWorkspace.ts b/interviewhub-lld/src/features/workspace/hooks/useWorkspace.ts new file mode 100644 index 00000000..a6002e40 --- /dev/null +++ b/interviewhub-lld/src/features/workspace/hooks/useWorkspace.ts @@ -0,0 +1,585 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import type React from 'react'; +import type { FileNode, Language, Problem } from '../../../types'; +import { workspaceApi, type WorkspaceDto } from '../api'; +import { + addNodeToTree, + clearDirtyFiles, + cloneFileTree, + findFileById, + findPreferredFile, + updateFileContent +} from '../lib/fileTree'; + +export type WorkspaceSaveStatus = 'idle' | 'loading' | 'saving' | 'saved' | 'error'; + +interface LocalWorkspace extends Pick { + baseRevision: number; + dirty: boolean; + mutationId: string; + updatedAt: string; + storageKey?: string; +} + +interface WorkspaceArgs { + problem: Problem | null; + language: Language; + authenticated: boolean; + userId?: string; +} + +const LOCAL_PREFIX = 'judgemycode_workspace'; +const SAVE_DEBOUNCE_MS = 800; + +// sessionStorage can be cloned when a browser tab is duplicated, so it cannot +// safely identify an editor instance. A module-scoped UUID is unique to this +// document and prevents two open tabs from writing the same local journal. +const WORKSPACE_TAB_ID = typeof crypto !== 'undefined' && 'randomUUID' in crypto + ? crypto.randomUUID() + : `document-${Date.now()}-${Math.random().toString(16).slice(2)}`; + +const localKeyPrefix = (problemId: string, language: Language, userId?: string): string => { + const owner = userId ? `user:${encodeURIComponent(userId)}` : 'anonymous'; + return `${LOCAL_PREFIX}:${owner}:${encodeURIComponent(problemId)}:${language}`; +}; + +const localKey = (problemId: string, language: Language, userId?: string): string => { + return `${localKeyPrefix(problemId, language, userId)}:tab:${WORKSPACE_TAB_ID}`; +}; + +const workspaceScopeKey = (problemId: string, language: Language, userId?: string): string => { + return `${userId || 'anonymous'}:${problemId}:${language}`; +}; + +const readLocal = (problemId: string, language: Language, userId?: string): LocalWorkspace | null => { + try { + const ownKey = localKey(problemId, language, userId); + const prefix = `${localKeyPrefix(problemId, language, userId)}:tab:`; + const legacyKey = localKeyPrefix(problemId, language, userId); + const keys = [ownKey, legacyKey]; + for (let index = 0; index < window.localStorage.length; index += 1) { + const key = window.localStorage.key(index); + if (key?.startsWith(prefix) && key !== ownKey) keys.push(key); + } + + const candidates: LocalWorkspace[] = []; + for (const key of keys) { + const raw = window.localStorage.getItem(key); + if (!raw) continue; + const parsed = JSON.parse(raw) as Partial; + if (!Array.isArray(parsed.files)) continue; + candidates.push({ + problemId, + language, + files: parsed.files, + activeFileId: parsed.activeFileId, + baseRevision: Number.isSafeInteger(parsed.baseRevision) && (parsed.baseRevision || 0) >= 0 ? parsed.baseRevision || 0 : 0, + // Legacy snapshots predate revision tracking. Treat them as unsynced so + // they can never silently replace a server draft. + dirty: parsed.dirty ?? true, + mutationId: typeof parsed.mutationId === 'string' ? parsed.mutationId : `legacy:${parsed.updatedAt || key}`, + updatedAt: typeof parsed.updatedAt === 'string' ? parsed.updatedAt : '', + storageKey: key + }); + if (key === ownKey) return candidates[candidates.length - 1]; + } + candidates.sort((left, right) => { + if (left.dirty !== right.dirty) return left.dirty ? -1 : 1; + return right.updatedAt.localeCompare(left.updatedAt); + }); + return candidates[0] || null; + } catch { + return null; + } +}; + +const writeLocal = (snapshot: LocalWorkspace, userId?: string): boolean => { + try { + const key = localKey(snapshot.problemId, snapshot.language, userId); + const { storageKey: _storageKey, ...persisted } = snapshot; + window.localStorage.setItem(key, JSON.stringify(persisted)); + snapshot.storageKey = key; + if (!snapshot.dirty) pruneSupersededCleanJournals(snapshot, key, userId); + return true; + } catch { + return false; + } +}; + +const pruneSupersededCleanJournals = (snapshot: LocalWorkspace, keepKey: string, userId?: string): void => { + const prefix = `${localKeyPrefix(snapshot.problemId, snapshot.language, userId)}:tab:`; + const removable: string[] = []; + for (let index = 0; index < window.localStorage.length; index += 1) { + const key = window.localStorage.key(index); + if (!key?.startsWith(prefix) || key === keepKey) continue; + try { + const raw = window.localStorage.getItem(key); + if (!raw) continue; + const candidate = JSON.parse(raw) as Partial; + const candidateRevision = Number.isSafeInteger(candidate.baseRevision) ? candidate.baseRevision || 0 : 0; + if (candidate.dirty === false && candidateRevision <= snapshot.baseRevision) removable.push(key); + } catch { + // Do not remove a journal that cannot be positively identified as a + // superseded clean snapshot; it may be the only recoverable local draft. + } + } + removable.forEach((key) => window.localStorage.removeItem(key)); +}; + +const markLocalSynced = (snapshot: LocalWorkspace, clean: LocalWorkspace, userId?: string): void => { + writeLocal(clean, userId); + if (!snapshot.storageKey || snapshot.storageKey === clean.storageKey) return; + try { + const raw = window.localStorage.getItem(snapshot.storageKey); + if (!raw) return; + const current = JSON.parse(raw) as Partial; + if (current.mutationId === snapshot.mutationId) { + const { storageKey: _storageKey, ...persisted } = clean; + window.localStorage.setItem(snapshot.storageKey, JSON.stringify(persisted)); + pruneSupersededCleanJournals(clean, clean.storageKey || localKey(clean.problemId, clean.language, userId), userId); + } + } catch { + // The active tab's clean copy was already written; another journal remains + // untouched when it cannot be proven to be the acknowledged mutation. + } +}; + +const createId = (): string => { + if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) return `file-${crypto.randomUUID()}`; + return `file-${Date.now()}-${Math.random().toString(16).slice(2)}`; +}; + +export interface WorkspaceController { + files: FileNode[]; + openFiles: FileNode[]; + activeFile: FileNode | null; + saveStatus: WorkspaceSaveStatus; + saveError: string; + hasConflict: boolean; + keepLocalVersion: () => void; + useServerVersion: () => void; + selectFile: (file: FileNode) => void; + closeTab: (file: FileNode | string, event?: React.MouseEvent) => void; + closeOtherTabs: () => void; + closeAllTabs: () => void; + changeContent: (content: string) => void; + createFile: (parentPath: string, fileName: string, isDirectory: boolean) => void; +} + +export const useWorkspace = ({ problem, language, authenticated, userId }: WorkspaceArgs): WorkspaceController => { + const [files, setFiles] = useState([]); + const [openFiles, setOpenFiles] = useState([]); + const [activeFile, setActiveFile] = useState(null); + const [saveStatus, setSaveStatus] = useState('idle'); + const [saveError, setSaveError] = useState(''); + const [hasConflict, setHasConflict] = useState(false); + + const activeKeyRef = useRef(''); + const saveTimerRef = useRef(null); + const saveAbortRef = useRef(null); + const revisionRef = useRef(0); + + const hydrate = useCallback((nextFiles: FileNode[], activeFileId?: string) => { + const cloned = cloneFileTree(nextFiles); + const selected = findFileById(cloned, activeFileId) || findPreferredFile(cloned); + setFiles(cloned); + setOpenFiles(selected ? [selected] : []); + setActiveFile(selected); + }, []); + + const persist = useCallback((nextFiles: FileNode[], activeFileId?: string) => { + if (!problem) return; + const key = workspaceScopeKey(problem.id, language, userId); + const local: LocalWorkspace = { + problemId: problem.id, + language, + files: nextFiles, + activeFileId, + baseRevision: revisionRef.current, + dirty: true, + mutationId: createId(), + updatedAt: new Date().toISOString() + }; + const localSaved = writeLocal(local, userId); + + if (saveTimerRef.current !== null) window.clearTimeout(saveTimerRef.current); + saveAbortRef.current?.abort(); + + if (!authenticated) { + setSaveStatus(localSaved ? 'saved' : 'error'); + setSaveError(localSaved ? '' : 'Unable to save this draft in browser storage.'); + return; + } + + if (hasConflict) { + setSaveStatus('error'); + setSaveError('This local draft conflicts with a newer server version. Choose which version to keep.'); + return; + } + + setSaveStatus('saving'); + setSaveError(''); + saveTimerRef.current = window.setTimeout(async () => { + if (activeKeyRef.current !== key) return; + const controller = new AbortController(); + saveAbortRef.current = controller; + const result = await workspaceApi.save(problem.id, { + language, + files: nextFiles, + activeFileId, + revision: revisionRef.current + }, { signal: controller.signal }); + if (controller.signal.aborted || activeKeyRef.current !== key) return; + if (!result.success) { + if (result.status === 409) { + setHasConflict(true); + setSaveError('This draft changed on another device. The server version was not overwritten.'); + setSaveStatus('error'); + return; + } + setSaveStatus('error'); + setSaveError(result.error || 'Workspace save failed. Your local draft is still available.'); + return; + } + setSaveStatus('saved'); + setSaveError(''); + const savedRevision = result.data?.revision; + if (typeof savedRevision === 'number') revisionRef.current = savedRevision; + const clean = clearDirtyFiles(nextFiles); + markLocalSynced(local, { + ...local, + files: clean, + baseRevision: revisionRef.current, + dirty: false, + updatedAt: result.data?.updatedAt || new Date().toISOString() + }, userId); + setFiles(clean); + setOpenFiles((current) => current.map((file) => findFileById(clean, file.id) || file)); + setActiveFile((current) => current ? findFileById(clean, current.id) || current : null); + }, SAVE_DEBOUNCE_MS); + }, [authenticated, hasConflict, language, problem, userId]); + + useEffect(() => { + if (saveTimerRef.current !== null) window.clearTimeout(saveTimerRef.current); + saveAbortRef.current?.abort(); + + if (!problem) { + activeKeyRef.current = ''; + hydrate([]); + setSaveStatus('idle'); + setSaveError(''); + return; + } + + const key = workspaceScopeKey(problem.id, language, userId); + activeKeyRef.current = key; + revisionRef.current = 0; + setHasConflict(false); + const starter = cloneFileTree(problem.starterFiles?.[language] || problem.starterFiles?.java || []); + const userLocal = readLocal(problem.id, language, userId); + // Anonymous work is adopted only when this account has no device-local + // draft. Optimistic revisions below decide whether it can be synced safely. + const anonymousLocal = authenticated && userId && !userLocal + ? readLocal(problem.id, language) + : null; + const local = userLocal || anonymousLocal; + hydrate(local?.files || starter, local?.activeFileId); + setSaveStatus(authenticated ? 'loading' : local ? 'saved' : 'idle'); + setSaveError(''); + + if (!authenticated) return; + const controller = new AbortController(); + saveAbortRef.current = controller; + + void workspaceApi.get(problem.id, language, { signal: controller.signal }).then(async (result) => { + if (controller.signal.aborted || activeKeyRef.current !== key) return; + + if (result.success && result.data) { + const remote = result.data; + const remoteFiles = Array.isArray(remote.files) ? remote.files : []; + const remoteRevision = Number.isSafeInteger(remote.revision) && remote.revision >= 0 ? remote.revision : 0; + const remoteHasDraft = remoteRevision > 0; + revisionRef.current = remoteRevision; + + if (!remoteHasDraft && !local) { + // The backend represents a missing workspace as an empty 200 response. + // Keep the catalog starter tree for a user's first authenticated visit. + hydrate(starter); + setSaveStatus('idle'); + return; + } + + if (!remoteHasDraft && local) { + hydrate(local.files, local.activeFileId); + const saved = await workspaceApi.save(problem.id, { + language, + files: local.files, + activeFileId: local.activeFileId, + revision: 0 + }, { signal: controller.signal }); + if (controller.signal.aborted || activeKeyRef.current !== key) return; + if (saved.success && saved.data) { + revisionRef.current = saved.data.revision; + const clean = clearDirtyFiles(local.files); + hydrate(clean, local.activeFileId); + markLocalSynced(local, { + ...local, + files: clean, + baseRevision: saved.data.revision, + dirty: false, + updatedAt: saved.data.updatedAt || new Date().toISOString() + }, userId); + setSaveStatus('saved'); + setSaveError(''); + } else { + if (saved.status === 409) setHasConflict(true); + setSaveStatus('error'); + setSaveError(saved.error || 'Unable to sync the local draft.'); + } + return; + } + + if (local?.dirty) { + hydrate(local.files, local.activeFileId); + if (local.baseRevision !== remoteRevision) { + setHasConflict(true); + setSaveStatus('error'); + setSaveError('A server draft changed since this local draft was created. Choose which version to keep; neither has been overwritten.'); + return; + } + + const saved = await workspaceApi.save(problem.id, { + language, + files: local.files, + activeFileId: local.activeFileId, + revision: remoteRevision + }, { signal: controller.signal }); + if (controller.signal.aborted || activeKeyRef.current !== key) return; + if (!saved.success || !saved.data) { + if (saved.status === 409) setHasConflict(true); + setSaveStatus('error'); + setSaveError(saved.status === 409 + ? 'The server draft changed while this local draft was syncing. Choose which version to keep.' + : saved.error || 'Unable to sync the local draft.'); + return; + } + revisionRef.current = saved.data.revision; + const clean = clearDirtyFiles(local.files); + hydrate(clean, local.activeFileId); + markLocalSynced(local, { + ...local, + files: clean, + baseRevision: saved.data.revision, + dirty: false, + updatedAt: saved.data.updatedAt || new Date().toISOString() + }, userId); + setSaveStatus('saved'); + setSaveError(''); + return; + } + + hydrate(remoteFiles, remote.activeFileId); + writeLocal({ + problemId: problem.id, + language, + files: clearDirtyFiles(remoteFiles), + activeFileId: remote.activeFileId, + baseRevision: remoteRevision, + dirty: false, + mutationId: createId(), + updatedAt: remote.updatedAt || new Date().toISOString() + }, userId); + setSaveStatus('saved'); + setSaveError(''); + return; + } + + if (result.status === 404) { + setSaveStatus('error'); + setSaveError('The problem is no longer available. Your local draft was preserved.'); + return; + } + + setSaveStatus('error'); + setSaveError(result.error || 'Unable to load the saved workspace. Using the local draft.'); + }); + + return () => controller.abort(); + }, [authenticated, hydrate, language, problem, userId]); + + useEffect(() => () => { + if (saveTimerRef.current !== null) window.clearTimeout(saveTimerRef.current); + saveAbortRef.current?.abort(); + }, []); + + const selectFile = useCallback((file: FileNode) => { + if (file.isDirectory) return; + setOpenFiles((current) => current.some((item) => item.id === file.id) ? current : [...current, file]); + setActiveFile(file); + }, []); + + const closeTab = useCallback((file: FileNode | string, event?: React.MouseEvent) => { + event?.stopPropagation(); + const fileId = typeof file === 'string' ? file : file.id; + setOpenFiles((current) => { + const next = current.filter((item) => item.id !== fileId); + setActiveFile((selected) => selected?.id === fileId ? next[next.length - 1] || null : selected); + return next; + }); + }, []); + + const closeOtherTabs = useCallback(() => { + if (activeFile) setOpenFiles([activeFile]); + }, [activeFile]); + + const closeAllTabs = useCallback(() => { + setOpenFiles([]); + setActiveFile(null); + }, []); + + const changeContent = useCallback((content: string) => { + if (!activeFile) return; + const updatedFiles = updateFileContent(files, activeFile.id, content); + const updatedActive = { ...activeFile, content, isDirty: true }; + setFiles(updatedFiles); + setActiveFile(updatedActive); + setOpenFiles((current) => current.map((file) => file.id === activeFile.id ? updatedActive : file)); + persist(updatedFiles, activeFile.id); + }, [activeFile, files, persist]); + + const createFile = useCallback((parentPath: string, fileName: string, isDirectory: boolean) => { + const normalizedParent = parentPath.replace(/\/$/, ''); + const path = normalizedParent ? `${normalizedParent}/${fileName}` : fileName; + const newNode: FileNode = { + id: createId(), + name: fileName, + path, + isDirectory, + language: isDirectory ? undefined : language, + content: isDirectory ? undefined : `// ${fileName}\n\n`, + children: isDirectory ? [] : undefined, + isDirty: !isDirectory + }; + const inserted = addNodeToTree(files, normalizedParent, newNode); + const updatedFiles = inserted.added ? inserted.nodes : [...files, newNode]; + setFiles(updatedFiles); + if (!isDirectory) { + setOpenFiles((current) => [...current, newNode]); + setActiveFile(newNode); + } + persist(updatedFiles, isDirectory ? activeFile?.id : newNode.id); + }, [activeFile?.id, files, language, persist]); + + const useServerVersion = useCallback(() => { + if (!problem || !authenticated) return; + const key = workspaceScopeKey(problem.id, language, userId); + if (saveTimerRef.current !== null) window.clearTimeout(saveTimerRef.current); + saveAbortRef.current?.abort(); + const controller = new AbortController(); + saveAbortRef.current = controller; + setSaveStatus('loading'); + setSaveError(''); + + void workspaceApi.get(problem.id, language, { signal: controller.signal }).then((result) => { + if (controller.signal.aborted || activeKeyRef.current !== key) return; + if (!result.success || !result.data) { + setSaveStatus('error'); + setSaveError(result.error || 'Unable to load the latest server draft.'); + return; + } + const remote = result.data; + revisionRef.current = remote.revision; + const clean = clearDirtyFiles(remote.files || []); + hydrate(clean, remote.activeFileId); + writeLocal({ + problemId: problem.id, + language, + files: clean, + activeFileId: remote.activeFileId, + baseRevision: remote.revision, + dirty: false, + mutationId: createId(), + updatedAt: remote.updatedAt || new Date().toISOString() + }, userId); + setHasConflict(false); + setSaveStatus('saved'); + }); + }, [authenticated, hydrate, language, problem, userId]); + + const keepLocalVersion = useCallback(() => { + if (!problem || !authenticated) return; + const key = workspaceScopeKey(problem.id, language, userId); + if (saveTimerRef.current !== null) window.clearTimeout(saveTimerRef.current); + saveAbortRef.current?.abort(); + const controller = new AbortController(); + saveAbortRef.current = controller; + const localFiles = cloneFileTree(files); + const activeFileId = activeFile?.id; + const localSnapshot: LocalWorkspace = { + problemId: problem.id, + language, + files: localFiles, + activeFileId, + baseRevision: revisionRef.current, + dirty: true, + mutationId: createId(), + updatedAt: new Date().toISOString() + }; + writeLocal(localSnapshot, userId); + setSaveStatus('saving'); + setSaveError(''); + + void workspaceApi.get(problem.id, language, { signal: controller.signal }).then(async (latest) => { + if (controller.signal.aborted || activeKeyRef.current !== key) return; + if (!latest.success || !latest.data) { + setSaveStatus('error'); + setSaveError(latest.error || 'Unable to check the latest server draft.'); + return; + } + const saved = await workspaceApi.save(problem.id, { + language, + files: localFiles, + activeFileId, + revision: latest.data.revision + }, { signal: controller.signal }); + if (controller.signal.aborted || activeKeyRef.current !== key) return; + if (!saved.success || !saved.data) { + setSaveStatus('error'); + setSaveError(saved.status === 409 + ? 'The server draft changed again. Review the conflict before retrying.' + : saved.error || 'Unable to save the local draft.'); + return; + } + revisionRef.current = saved.data.revision; + const clean = clearDirtyFiles(localFiles); + hydrate(clean, activeFileId); + markLocalSynced(localSnapshot, { + ...localSnapshot, + files: clean, + baseRevision: saved.data.revision, + dirty: false, + updatedAt: saved.data.updatedAt || new Date().toISOString() + }, userId); + setHasConflict(false); + setSaveStatus('saved'); + setSaveError(''); + }); + }, [activeFile?.id, authenticated, files, hydrate, language, problem, userId]); + + return { + files, + openFiles, + activeFile, + saveStatus, + saveError, + hasConflict, + keepLocalVersion, + useServerVersion, + selectFile, + closeTab, + closeOtherTabs, + closeAllTabs, + changeContent, + createFile + }; +}; diff --git a/interviewhub-lld/src/features/workspace/lib/fileTree.ts b/interviewhub-lld/src/features/workspace/lib/fileTree.ts new file mode 100644 index 00000000..6a654cc4 --- /dev/null +++ b/interviewhub-lld/src/features/workspace/lib/fileTree.ts @@ -0,0 +1,92 @@ +import type { FileNode, Language } from '../../../types'; + +export const cloneFileTree = (nodes: FileNode[]): FileNode[] => { + return nodes.map((node) => ({ + ...node, + children: node.children ? cloneFileTree(node.children) : undefined + })); +}; + +export const findFileById = (nodes: FileNode[], id?: string): FileNode | null => { + if (!id) return null; + for (const node of nodes) { + if (!node.isDirectory && node.id === id) return node; + const nested = node.children ? findFileById(node.children, id) : null; + if (nested) return nested; + } + return null; +}; + +const findFirstFile = (nodes: FileNode[]): FileNode | null => { + for (const node of nodes) { + if (!node.isDirectory) return node; + const nested = node.children ? findFirstFile(node.children) : null; + if (nested) return nested; + } + return null; +}; + +export const findPreferredFile = (nodes: FileNode[]): FileNode | null => { + for (const node of nodes) { + if (!node.isDirectory && /(?:main|app)/i.test(node.name)) return node; + const nested = node.children ? findPreferredFile(node.children) : null; + if (nested && /(?:main|app)/i.test(nested.name)) return nested; + } + return findFirstFile(nodes); +}; + +export const updateFileContent = ( + nodes: FileNode[], + fileId: string, + content: string +): FileNode[] => { + return nodes.map((node) => { + if (node.id === fileId) return { ...node, content, isDirty: true }; + return node.children + ? { ...node, children: updateFileContent(node.children, fileId, content) } + : node; + }); +}; + +export const clearDirtyFiles = (nodes: FileNode[]): FileNode[] => { + return nodes.map((node) => ({ + ...node, + isDirty: false, + children: node.children ? clearDirtyFiles(node.children) : undefined + })); +}; + +export interface AddNodeResult { + nodes: FileNode[]; + added: boolean; +} + +export const addNodeToTree = ( + nodes: FileNode[], + parentPath: string, + child: FileNode +): AddNodeResult => { + let added = false; + const next = nodes.map((node) => { + if (node.isDirectory && node.path === parentPath) { + added = true; + return { ...node, children: [...(node.children || []), child] }; + } + if (!node.children) return node; + const nested = addNodeToTree(node.children, parentPath, child); + if (nested.added) added = true; + return nested.added ? { ...node, children: nested.nodes } : node; + }); + return { nodes: next, added }; +}; + +export const defaultFileName = (language: Language, suffix: string): string => { + const extensions: Record = { + java: 'java', + python: 'py', + go: 'go', + cpp: 'cpp', + typescript: 'ts' + }; + return `NewFile_${suffix}.${extensions[language]}`; +}; diff --git a/interviewhub-lld/src/services/apiClient.ts b/interviewhub-lld/src/services/apiClient.ts index 6a640c5a..b9993510 100644 --- a/interviewhub-lld/src/services/apiClient.ts +++ b/interviewhub-lld/src/services/apiClient.ts @@ -1,215 +1,133 @@ -import { Problem, FileNode, Language, TestCase } from '../types'; +/** + * Compatibility facade for existing components. + * + * New feature code should import its domain API directly from a feature API module. + * Keeping this facade small lets the UI migrate incrementally without restoring + * the former all-purpose networking implementation. + */ +import type { FileNode, Language, Problem } from '../types'; +import { tokenStore } from '../shared/api'; +import type { ApiRequestOptions } from '../shared/api'; +import { authApi, type AuthProvider } from '../features/auth/api'; +import { problemsApi } from '../features/problems/api'; +import { submissionsApi } from '../features/submissions/api'; +import { aiApi } from '../features/ai/api'; +import { adminProblemsApi, type ProblemUpdate } from '../features/admin/api'; +import { workspaceApi } from '../features/workspace/api'; +import { executionApi, type ExecutionPayload } from '../features/execution/api'; -const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:5050/api'; +class ApiCompatibilityFacade { + setToken(token: string | null): Promise { + return tokenStore.setAccessToken(token); + } -class ApiClient { - private token: string | null = localStorage.getItem('interviewhub_token'); - private refreshToken: string | null = localStorage.getItem('interviewhub_refresh_token'); + getToken(): string | null { + return tokenStore.getAccessToken(); + } - public setToken(token: string | null) { - this.token = token; - if (token) localStorage.setItem('interviewhub_token', token); - else localStorage.removeItem('interviewhub_token'); + setRefreshToken(token: string | null): Promise { + return tokenStore.setRefreshToken(token); } - public getToken(): string | null { - return this.token; + getRefreshToken(): string | null { + return tokenStore.getRefreshToken(); } - public setRefreshToken(token: string | null) { - this.refreshToken = token; - if (token) localStorage.setItem('interviewhub_refresh_token', token); - else localStorage.removeItem('interviewhub_refresh_token'); + getOAuthUrl(provider: AuthProvider): string { + return authApi.getOAuthUrl(provider); } - public getRefreshToken(): string | null { - return this.refreshToken; + getProblems(): Promise { + return problemsApi.list(); } - public getOAuthUrl(provider: 'google' | 'github' | 'linkedin'): string { - return `${API_BASE_URL}/auth/oauth/${provider}`; + async getProblemById(id: string): Promise { + const result = await problemsApi.get(id); + return result.success && result.data ? result.data : null; } - public async fetchOAuthAuthUrl(provider: 'google' | 'github' | 'linkedin'): Promise<{ success: boolean; data?: { url: string }; error?: string }> { - return this.request<{ url: string }>(`/auth/oauth/${provider}?format=json`); + createProblem(problem: Partial) { + return adminProblemsApi.create(problem); } - private async request(endpoint: string, options: RequestInit = {}): Promise<{ success: boolean; data?: T; error?: string; message?: string }> { - const headers: Record = { - 'Content-Type': 'application/json', - ...(options.headers as Record || {}) - }; + updateProblem(id: string, updates: ProblemUpdate) { + return adminProblemsApi.update(id, updates); + } + + deleteProblem(id: string) { + return adminProblemsApi.remove(id); + } + + resetProblems() { + return adminProblemsApi.reset(); + } - if (this.token) { - headers['Authorization'] = `Bearer ${this.token}`; - } + runCode(payload: ExecutionPayload) { + return executionApi.enqueueRun(payload); + } + + submitCode(payload: ExecutionPayload) { + return executionApi.enqueueSubmit(payload); + } + + async getSubmissions(problemId?: string) { + const result = await submissionsApi.list(problemId, { limit: 100 }); + return { + ...result, + data: result.data?.items + }; + } - try { - const res = await fetch(`${API_BASE_URL}${endpoint}`, { - ...options, - headers - }); - const data = await res.json(); - return data; - } catch (err: any) { - console.warn(`[API] Network request to ${endpoint} failed.`, err); - return { - success: false, - error: err.message || 'Failed to connect to backend server' - }; - } + getSubmissionsPage(problemId?: string, cursor?: string, limit = 50) { + return submissionsApi.list(problemId, { cursor, limit }); } - // --- Problems --- - public async getProblems(): Promise { - const res = await this.request('/problems'); - if (res.success && Array.isArray(res.data)) { - return res.data; - } - return []; + getSubmission(id: string) { + return submissionsApi.get(id); } - public async getProblemById(id: string): Promise { - const res = await this.request(`/problems/${id}`); - if (res.success && res.data) { - return res.data; - } - return null; - } - - public async createProblem(problem: Partial): Promise<{ success: boolean; data?: Problem; error?: string }> { - return this.request('/admin/problems', { - method: 'POST', - body: JSON.stringify(problem) - }); + getWorkspace(problemId: string, language: Language) { + return workspaceApi.get(problemId, language); } - public async updateProblem(id: string, updates: Partial): Promise<{ success: boolean; data?: Problem; error?: string }> { - return this.request(`/admin/problems/${id}`, { - method: 'PUT', - body: JSON.stringify(updates) - }); + saveWorkspace( + problemId: string, + workspace: { language: Language; files: FileNode[]; activeFileId?: string; revision: number } + ) { + return workspaceApi.save(problemId, workspace); } - public async deleteProblem(id: string): Promise<{ success: boolean; error?: string }> { - return this.request(`/admin/problems/${id}`, { - method: 'DELETE' - }); - } - - public async resetProblems(): Promise<{ success: boolean; data?: Problem[]; error?: string }> { - return this.request('/admin/problems/reset/defaults', { - method: 'POST' - }); + sendAiMessage(problemId: string, prompt: string, codeSnippet?: string, options: ApiRequestOptions = {}) { + return aiApi.send(problemId, prompt, codeSnippet, options); } - - // --- Execution & Submissions --- - public async runCode(payload: { problemId: string; language: Language; files: FileNode[]; testCases?: TestCase[] }) { - return this.request('/execute/run', { - method: 'POST', - body: JSON.stringify(payload) - }); - } - - public async submitCode(payload: { problemId: string; language: Language; files: FileNode[]; testCases?: TestCase[] }) { - return this.request('/execute/submit', { - method: 'POST', - body: JSON.stringify(payload) - }); - } - - public async getSubmissions(problemId?: string) { - const query = problemId ? `?problemId=${problemId}` : ''; - return this.request(`/submissions${query}`); - } - - public async getWorkspace(problemId: string, language: Language) { - return this.request(`/problems/${problemId}/workspace?language=${encodeURIComponent(language)}`); - } - - public async saveWorkspace(problemId: string, workspace: { language: Language; files: FileNode[]; activeFileId?: string }) { - return this.request(`/problems/${problemId}/workspace`, { - method: 'PUT', - body: JSON.stringify(workspace) - }); - } - - // --- AI Interviewer --- - public async sendAiMessage(problemId: string, prompt: string, codeSnippet?: string) { - return this.request<{ role: string; category: string; content: string; timestamp: string }>('/ai/chat', { - method: 'POST', - body: JSON.stringify({ problemId, prompt, codeSnippet }) - }); - } - - // --- Auth --- - public async exchangeOAuthCode(code: string) { - const res = await this.request<{ user: any; token: string; refreshToken?: string }>('/auth/exchange', { - method: 'POST', - body: JSON.stringify({ code }) - }); - if (res.success && res.data?.token) { - this.setToken(res.data.token); - if (res.data.refreshToken) this.setRefreshToken(res.data.refreshToken); - } - return res; + + exchangeOAuthCode(code: string) { + return authApi.exchangeOAuthCode(code); } - public async login(email: string, password: string) { - const res = await this.request<{ user: any; token: string; refreshToken?: string }>('/auth/login', { - method: 'POST', - body: JSON.stringify({ email, password }) - }); - if (res.success && res.data?.token) { - this.setToken(res.data.token); - if (res.data.refreshToken) this.setRefreshToken(res.data.refreshToken); - } - return res; + login(email: string, password: string) { + return authApi.login(email, password); } - public async register(name: string, email: string, password: string) { - const res = await this.request<{ user: any; token: string; refreshToken?: string }>('/auth/register', { - method: 'POST', - body: JSON.stringify({ name, email, password }) - }); - if (res.success && res.data?.token) { - this.setToken(res.data.token); - if (res.data.refreshToken) this.setRefreshToken(res.data.refreshToken); - } - return res; + register(name: string, email: string, password: string) { + return authApi.register(name, email, password); } - public async getMe() { - return this.request('/auth/me'); + getMe() { + return authApi.getMe(); } - public async getMyProgress() { - return this.request('/users/me/progress'); + getMyProgress() { + return submissionsApi.getProgress(); } - public async refreshSession() { - if (!this.refreshToken) return { success: false, error: 'No saved session' }; - const res = await this.request<{ token: string; refreshToken: string }>('/auth/refresh', { - method: 'POST', - body: JSON.stringify({ refreshToken: this.refreshToken }) - }); - if (res.success && res.data?.token) { - this.setToken(res.data.token); - this.setRefreshToken(res.data.refreshToken); - } - return res; - } + refreshSession() { + return authApi.refreshSession(); + } - public async logout() { - if (this.refreshToken) { - await this.request('/auth/logout', { - method: 'POST', - body: JSON.stringify({ refreshToken: this.refreshToken }) - }); - } - this.setToken(null); - this.setRefreshToken(null); + logout() { + return authApi.logout(); } } -export const api = new ApiClient(); +export const api = new ApiCompatibilityFacade(); diff --git a/interviewhub-lld/src/shared/api/httpClient.ts b/interviewhub-lld/src/shared/api/httpClient.ts new file mode 100644 index 00000000..91216e14 --- /dev/null +++ b/interviewhub-lld/src/shared/api/httpClient.ts @@ -0,0 +1,307 @@ +import type { ApiEnvelope, ApiRequestOptions, ApiResult, TokenPair } from './types.ts'; +import { tokenStore } from './tokenStore.ts'; +import type { TokenSnapshot } from './tokenStore.ts'; + +const DEFAULT_TIMEOUT_MS = 15_000; +const API_BASE_URL = (import.meta.env?.VITE_API_BASE_URL || 'http://localhost:5050/api').replace(/\/$/, ''); + +const isEnvelope = (value: unknown): value is ApiEnvelope => { + return Boolean(value) && typeof value === 'object' && typeof (value as { success?: unknown }).success === 'boolean'; +}; + +const errorMessage = (value: unknown, fallback: string): string => { + if (value && typeof value === 'object') { + const candidate = value as { error?: unknown; message?: unknown }; + if (typeof candidate.error === 'string' && candidate.error) return candidate.error; + if (typeof candidate.message === 'string' && candidate.message) return candidate.message; + } + return fallback; +}; + +class HttpClient { + private readonly refreshInFlight = new Map>>(); + + get baseUrl(): string { + return API_BASE_URL; + } + + async request(endpoint: string, options: ApiRequestOptions = {}): Promise> { + const retryUnauthorized = options.retryUnauthorized !== false; + const sessionAtRequest = tokenStore.getSnapshot(); + const result = await this.fetchOnce(endpoint, options, sessionAtRequest.accessToken); + if (result.status !== 401 || !retryUnauthorized || endpoint.startsWith('/auth/refresh')) return result; + if (!sessionAtRequest.accessToken && !sessionAtRequest.refreshToken) return result; + + let current = tokenStore.getSnapshot(); + if (!tokenStore.isSameSession(sessionAtRequest, current)) { + return this.sessionChangedResult(); + } + + // A different tab may already have rotated this same login while the first + // request was in flight. Its access token can be retried without rotating a + // second time, but only inside the same principal + login epoch. + if (current.accessToken === sessionAtRequest.accessToken) { + if (!current.refreshToken) { + if (this.hasExactCredentials(current, sessionAtRequest)) { + try { + if (!await tokenStore.clearIfUnchanged(sessionAtRequest)) return this.sessionChangedResult(); + } catch (error: unknown) { + return this.sessionPersistenceResult(error); + } + } + return result; + } + const refreshResult = await this.refreshSession(); + if (!refreshResult.success || !refreshResult.data?.token) { + if (refreshResult.status === 0 || refreshResult.status === 429 || refreshResult.status >= 500) { + return { + success: false, + error: refreshResult.error || 'Session refresh is temporarily unavailable', + status: refreshResult.status + }; + } + return refreshResult as ApiResult; + } + current = tokenStore.getSnapshot(); + } + + if (!tokenStore.isSameSession(sessionAtRequest, current) || !current.accessToken) { + return this.sessionChangedResult(); + } + + const sessionAtRetry = current; + const retried = await this.fetchOnce( + endpoint, + { ...options, retryUnauthorized: false }, + sessionAtRetry.accessToken + ); + if (retried.status === 401) { + const latest = tokenStore.getSnapshot(); + if (this.hasExactCredentials(latest, sessionAtRetry)) { + try { + if (!await tokenStore.clearIfUnchanged(sessionAtRetry)) return this.sessionChangedResult(); + } catch (error: unknown) { + return this.sessionPersistenceResult(error); + } + } + } + return retried; + } + + private async fetchOnce( + endpoint: string, + options: ApiRequestOptions, + boundAccessToken: string | null + ): Promise> { + const controller = new AbortController(); + const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; + let timedOut = false; + + const timeoutId = window.setTimeout(() => { + timedOut = true; + controller.abort(); + }, timeoutMs); + + const externalAbort = () => controller.abort(); + if (options.signal?.aborted) controller.abort(); + else options.signal?.addEventListener('abort', externalAbort, { once: true }); + + const headers = new Headers(options.headers); + headers.set('Accept', 'application/json'); + if (options.body && !(typeof FormData !== 'undefined' && options.body instanceof FormData)) { + if (!headers.has('Content-Type')) headers.set('Content-Type', 'application/json'); + } + + // Bind identity at request creation. Header construction can run arbitrary + // iterable code, and another tab can replace storage at any point; neither + // is allowed to attach account B's bearer to account A's request body. + if (boundAccessToken) headers.set('Authorization', `Bearer ${boundAccessToken}`); + else headers.delete('Authorization'); + + try { + const { + timeoutMs: _timeoutMs, + retryUnauthorized: _retryUnauthorized, + signal: _externalSignal, + ...requestOptions + } = options; + const response = await fetch(`${API_BASE_URL}${endpoint}`, { + ...requestOptions, + signal: controller.signal, + headers + }); + + if (response.status === 204) { + return { + success: response.ok, + status: response.status, + error: response.ok ? undefined : response.statusText || 'Request failed' + }; + } + + const rawBody = await response.text(); + let parsed: unknown; + if (rawBody) { + try { + parsed = JSON.parse(rawBody); + } catch { + parsed = rawBody; + } + } + + if (isEnvelope(parsed)) { + return { + success: response.ok && parsed.success, + data: parsed.data, + error: parsed.error || (!response.ok ? response.statusText || 'Request failed' : undefined), + message: parsed.message, + status: response.status + }; + } + + if (response.ok) { + return { + success: true, + data: parsed as T, + message: typeof parsed === 'string' ? parsed : undefined, + status: response.status + }; + } + + return { + success: false, + error: errorMessage(parsed, response.statusText || `Request failed (${response.status})`), + status: response.status + }; + } catch (error) { + const aborted = controller.signal.aborted; + const message = timedOut + ? `Request timed out after ${timeoutMs}ms` + : aborted + ? 'Request cancelled' + : error instanceof Error + ? error.message + : 'Failed to connect to backend server'; + + return { success: false, error: message, status: 0 }; + } finally { + window.clearTimeout(timeoutId); + options.signal?.removeEventListener('abort', externalAbort); + } + } + + refreshSession(options: ApiRequestOptions = {}): Promise> { + const observed = tokenStore.getSnapshot(); + if (!observed.refreshToken || !observed.principalId || !observed.sessionEpoch) { + return Promise.resolve({ success: false, error: 'No saved session', status: 401 }); + } + const flightKey = `${observed.principalId}\u0000${observed.sessionEpoch}`; + const existingFlight = this.refreshInFlight.get(flightKey); + if (existingFlight) return existingFlight; + + const refreshPromise = tokenStore.withSessionLock>(async (locked) => { + const current = locked.getSnapshot(); + if (!current.refreshToken || !tokenStore.isSameSession(current, observed)) { + return this.sessionChangedResult(); + } + + // Another tab may have completed rotation while this tab waited for the + // cross-document lock. Reuse its pair instead of replaying the old token. + if (current.refreshToken !== observed.refreshToken && current.accessToken) { + return { + success: true, + data: { token: current.accessToken, refreshToken: current.refreshToken }, + status: 200 + }; + } + + const tokenToRotate = current.refreshToken; + const sessionToRotate = current; + const result = await this.fetchOnce('/auth/refresh', { + ...options, + method: 'POST', + body: JSON.stringify({ refreshToken: tokenToRotate }), + retryUnauthorized: false + }, null); + const latest = locked.getSnapshot(); + + if (result.success && result.data?.token) { + // Logout or a new login may have happened while the request was in + // flight. Never resurrect or overwrite that newer session. + if (this.hasExactCredentials(latest, sessionToRotate)) { + const rotated = locked.rotateSession( + result.data.token, + result.data.refreshToken || tokenToRotate, + sessionToRotate + ); + if (!rotated) return this.sessionChangedResult(); + return result; + } + if (tokenStore.isSameSession(latest, observed) && latest.accessToken && latest.refreshToken) { + return { + success: true, + data: { token: latest.accessToken, refreshToken: latest.refreshToken }, + status: 200 + }; + } + return { success: false, error: 'Session changed while it was being refreshed', status: 401 }; + } + + // Do not clear credentials written by another tab while this request was + // in flight. If a newer complete pair exists, it is already the winner. + if ( + tokenStore.isSameSession(latest, observed) && + latest.refreshToken !== tokenToRotate && + latest.accessToken && + latest.refreshToken + ) { + return { + success: true, + data: { token: latest.accessToken, refreshToken: latest.refreshToken }, + status: 200 + }; + } + if (this.hasExactCredentials(latest, sessionToRotate) && (result.status === 400 || result.status === 401)) { + locked.clear(); + } + return result; + }) + .catch((error: unknown) => { + return { + success: false, + error: error instanceof Error ? error.message : 'Unable to refresh session', + status: 0 + } satisfies ApiResult; + }) + .finally(() => { + this.refreshInFlight.delete(flightKey); + }); + + this.refreshInFlight.set(flightKey, refreshPromise); + return refreshPromise; + } + + private hasExactCredentials(left: TokenSnapshot, right: TokenSnapshot): boolean { + return tokenStore.isSameSession(left, right) && + left.accessToken === right.accessToken && + left.refreshToken === right.refreshToken; + } + + private sessionChangedResult(): ApiResult { + return { + success: false, + error: 'Session changed while the request was in progress', + status: 401 + }; + } + + private sessionPersistenceResult(error: unknown): ApiResult { + return { + success: false, + error: error instanceof Error ? error.message : 'Unable to update the browser session safely', + status: 0 + }; + } +} + +export const httpClient = new HttpClient(); diff --git a/interviewhub-lld/src/shared/api/index.ts b/interviewhub-lld/src/shared/api/index.ts new file mode 100644 index 00000000..ba46e9e7 --- /dev/null +++ b/interviewhub-lld/src/shared/api/index.ts @@ -0,0 +1,4 @@ +export { httpClient } from './httpClient'; +export { isSameSession, tokenStore } from './tokenStore'; +export type { TokenSnapshot } from './tokenStore'; +export type { ApiEnvelope, ApiRequestOptions, ApiResult, TokenPair } from './types'; diff --git a/interviewhub-lld/src/shared/api/tokenStore.ts b/interviewhub-lld/src/shared/api/tokenStore.ts new file mode 100644 index 00000000..c8ae6db2 --- /dev/null +++ b/interviewhub-lld/src/shared/api/tokenStore.ts @@ -0,0 +1,550 @@ +const ACCESS_TOKEN_KEY = 'interviewhub_token'; +const REFRESH_TOKEN_KEY = 'interviewhub_refresh_token'; +const SESSION_KEY = 'judgemycode_session_v2'; +const PENDING_LOGOUT_KEY = 'judgemycode_pending_logout'; +const PENDING_LOGOUT_PREFIX = 'judgemycode_pending_logout_item:'; +const SESSION_LOCK_NAME = 'judgemycode-session-mutation'; +const LOGOUT_DRAIN_LOCK_NAME = 'judgemycode-logout-drain'; +const LOCK_WAIT_MS = 35_000; +const MAX_PENDING_LOGOUTS = 128; + +export interface TokenSnapshot { + accessToken: string | null; + refreshToken: string | null; + principalId: string | null; + sessionEpoch: string | null; +} + +interface PendingLogout { + refreshToken: string; + queuedAt: number; + storageKey: string; +} + +type TokenListener = (snapshot: TokenSnapshot) => void; +type PendingLogoutListener = () => void; + +const listeners = new Set(); +const pendingLogoutListeners = new Set(); + +interface BrowserLockManager { + request( + name: string, + options: { signal: AbortSignal }, + callback: () => Promise + ): Promise; +} + +export interface LockedTokenStore { + getSnapshot(): TokenSnapshot; + replaceSession(accessToken: string | null, refreshToken: string | null): void; + rotateSession(accessToken: string, refreshToken: string, expected: TokenSnapshot): boolean; + clear(): void; + clearIfUnchanged(expected: TokenSnapshot): boolean; + beginLogout(): string | null; + completeLogout(refreshToken: string): void; +} + +const emptySnapshot = (): TokenSnapshot => ({ + accessToken: null, + refreshToken: null, + principalId: null, + sessionEpoch: null +}); + +const storage = (): Storage | null => { + if (typeof window === 'undefined') return null; + return window.localStorage; +}; + +const read = (key: string): string | null => { + try { + return storage()?.getItem(key) ?? null; + } catch { + return null; + } +}; + +const decodePrincipal = (accessToken: string | null): string | null => { + if (!accessToken) return null; + try { + const parts = accessToken.split('.'); + if (parts.length !== 3 || !parts[1]) return null; + const normalized = parts[1].replace(/-/g, '+').replace(/_/g, '/'); + const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '='); + const bytes = Uint8Array.from(globalThis.atob(padded), (character) => character.charCodeAt(0)); + const claims = JSON.parse(new TextDecoder().decode(bytes)) as { sub?: unknown; id?: unknown }; + const candidate = typeof claims.sub === 'string' && claims.sub.trim() + ? claims.sub.trim() + : typeof claims.id === 'string' && claims.id.trim() + ? claims.id.trim() + : ''; + return candidate && candidate.length <= 256 ? candidate : null; + } catch { + return null; + } +}; + +const createSessionEpoch = (): string => { + return globalThis.crypto?.randomUUID?.() || `${Date.now()}-${Math.random().toString(16).slice(2)}`; +}; + +const legacySessionEpoch = (accessToken: string | null, refreshToken: string | null): string | null => { + if (accessToken) { + const signature = accessToken.split('.')[2] || accessToken; + return `legacy-access:${signature.slice(-96)}`; + } + if (refreshToken) return `legacy-refresh:${refreshToken.slice(-96)}`; + return null; +}; + +const normalizeSnapshot = (candidate: Partial): TokenSnapshot => { + const accessToken = typeof candidate.accessToken === 'string' && candidate.accessToken + ? candidate.accessToken + : null; + const refreshToken = typeof candidate.refreshToken === 'string' && candidate.refreshToken + ? candidate.refreshToken + : null; + if (!accessToken && !refreshToken) return emptySnapshot(); + + const decodedPrincipal = decodePrincipal(accessToken); + const storedPrincipal = typeof candidate.principalId === 'string' && candidate.principalId.trim() + ? candidate.principalId.trim() + : null; + const principalId = decodedPrincipal || (!accessToken ? storedPrincipal : null); + const storedEpoch = typeof candidate.sessionEpoch === 'string' && candidate.sessionEpoch + ? candidate.sessionEpoch + : null; + + return { + accessToken, + refreshToken, + principalId, + sessionEpoch: storedEpoch || legacySessionEpoch(accessToken, refreshToken) + }; +}; + +const snapshot = (): TokenSnapshot => { + let current: TokenSnapshot; + const serialized = read(SESSION_KEY); + if (serialized) { + try { + current = normalizeSnapshot(JSON.parse(serialized) as Partial); + // If the browser stopped between queuing a logout and clearing the + // session key, complete the local half of that logout before bootstrap. + if (current.refreshToken && readPendingLogouts().some((entry) => entry.refreshToken === current.refreshToken)) return emptySnapshot(); + return current; + } catch { + // Fall through to the one-time legacy token migration below. + } + } + current = normalizeSnapshot({ + accessToken: read(ACCESS_TOKEN_KEY), + refreshToken: read(REFRESH_TOKEN_KEY) + }); + if (current.refreshToken && readPendingLogouts().some((entry) => entry.refreshToken === current.refreshToken)) return emptySnapshot(); + return current; +}; + +const writeSnapshot = (next: TokenSnapshot): boolean => { + try { + const target = storage(); + if (!target) return false; + if (next.accessToken || next.refreshToken) target.setItem(SESSION_KEY, JSON.stringify(next)); + else target.removeItem(SESSION_KEY); + } catch { + // Storage can be unavailable in private or embedded browser contexts. + return false; + } + // Legacy cleanup is best effort after the authoritative single-key commit; + // failure here must not misreport a successfully persisted new session. + try { + const target = storage(); + target?.removeItem(ACCESS_TOKEN_KEY); + target?.removeItem(REFRESH_TOKEN_KEY); + } catch { + // The v2 document remains authoritative. + } + return true; +}; + +const persistedRefreshToken = (): string | null => { + const serialized = read(SESSION_KEY); + if (serialized) { + try { + const parsed = JSON.parse(serialized) as Partial; + if (typeof parsed.refreshToken === 'string' && parsed.refreshToken) return parsed.refreshToken; + } catch { + // A malformed v2 value cannot be trusted; inspect only the legacy slot. + } + } + return read(REFRESH_TOKEN_KEY); +}; + +const emit = (): void => { + const current = snapshot(); + listeners.forEach((listener) => listener(current)); +}; + +export const isSameSession = (left: TokenSnapshot, right: TokenSnapshot): boolean => { + return Boolean( + left.principalId && + left.sessionEpoch && + left.principalId === right.principalId && + left.sessionEpoch === right.sessionEpoch + ); +}; + +const parsePendingLogout = (value: unknown, storageKey: string): PendingLogout | null => { + if (!value || typeof value !== 'object') return null; + const candidate = value as Partial; + if (typeof candidate.refreshToken !== 'string' || !candidate.refreshToken) return null; + return { + refreshToken: candidate.refreshToken, + queuedAt: typeof candidate.queuedAt === 'number' && Number.isFinite(candidate.queuedAt) + ? candidate.queuedAt + : 0, + storageKey + }; +}; + +const parseLegacyPendingLogouts = (raw: string): Array> => { + try { + const parsed = JSON.parse(raw) as unknown; + if (Array.isArray(parsed)) { + return parsed + .map((value) => parsePendingLogout(value, '')) + .filter((value): value is PendingLogout => Boolean(value)) + .map(({ refreshToken, queuedAt }) => ({ refreshToken, queuedAt })); + } + if (typeof parsed === 'string' && parsed) return [{ refreshToken: parsed, queuedAt: 0 }]; + const entry = parsePendingLogout(parsed, ''); + return entry ? [{ refreshToken: entry.refreshToken, queuedAt: entry.queuedAt }] : []; + } catch { + // The oldest format stored exactly one raw refresh token. + return raw ? [{ refreshToken: raw, queuedAt: 0 }] : []; + } +}; + +const createPendingStorageKey = (target: Storage): string => { + for (;;) { + const id = globalThis.crypto?.randomUUID?.() || `${Date.now()}-${Math.random().toString(16).slice(2)}`; + const key = `${PENDING_LOGOUT_PREFIX}${id}`; + if (target.getItem(key) === null) return key; + } +}; + +const writePendingLogoutItem = ( + target: Storage, + entry: Omit +): string | null => { + try { + const storageKey = createPendingStorageKey(target); + target.setItem(storageKey, JSON.stringify(entry)); + return storageKey; + } catch { + return null; + } +}; + +const migrateLegacyPendingLogouts = (target: Storage): void => { + const raw = target.getItem(PENDING_LOGOUT_KEY); + if (!raw) return; + const legacy = parseLegacyPendingLogouts(raw); + const written: string[] = []; + for (const entry of legacy) { + const storageKey = writePendingLogoutItem(target, entry); + if (!storageKey) { + written.forEach((key) => target.removeItem(key)); + return; + } + written.push(storageKey); + } + target.removeItem(PENDING_LOGOUT_KEY); +}; + +const readPendingLogouts = (): PendingLogout[] => { + try { + const target = storage(); + if (!target) return []; + migrateLegacyPendingLogouts(target); + const result: PendingLogout[] = []; + for (let index = 0; index < target.length; index += 1) { + const storageKey = target.key(index); + if (!storageKey?.startsWith(PENDING_LOGOUT_PREFIX)) continue; + const raw = target.getItem(storageKey); + if (!raw) continue; + try { + const parsed = parsePendingLogout(JSON.parse(raw), storageKey); + if (parsed) result.push(parsed); + } catch { + // An invalid item is retained for operator/browser recovery rather than + // being mistaken for a successfully revoked credential. + } + } + result.sort((left, right) => left.queuedAt - right.queuedAt || left.storageKey.localeCompare(right.storageKey)); + return result; + } catch { + return []; + } +}; + +const emitPendingLogouts = (): void => { + pendingLogoutListeners.forEach((listener) => listener()); +}; + +const enqueuePendingLogout = (refreshToken: string): boolean => { + try { + const target = storage(); + if (!target) return false; + const pending = readPendingLogouts(); + if (pending.some((entry) => entry.refreshToken === refreshToken)) return true; + if (new Set(pending.map((entry) => entry.refreshToken)).size >= MAX_PENDING_LOGOUTS) return false; + const written = writePendingLogoutItem(target, { refreshToken, queuedAt: Date.now() }); + if (!written) return false; + emitPendingLogouts(); + return true; + } catch { + return false; + } +}; + +const removePendingLogout = (refreshToken: string): boolean => { + try { + const target = storage(); + if (!target) return false; + const matchingKeys = readPendingLogouts() + .filter((entry) => entry.refreshToken === refreshToken) + .map((entry) => entry.storageKey); + matchingKeys.forEach((key) => target.removeItem(key)); + emitPendingLogouts(); + return true; + } catch { + return false; + } +}; + +const withBrowserLock = (name: string, task: () => Promise): Promise => { + const lockManager = typeof navigator === 'undefined' + ? undefined + : (navigator as unknown as { locks?: BrowserLockManager }).locks; + if (typeof window === 'undefined') return task(); + if (!lockManager?.request) { + return Promise.reject(new Error('This browser cannot safely coordinate session changes across tabs')); + } + const controller = new AbortController(); + const timeout = globalThis.setTimeout(() => controller.abort(), LOCK_WAIT_MS); + return lockManager.request(name, { signal: controller.signal }, task).finally(() => { + globalThis.clearTimeout(timeout); + }); +}; + +const persistSnapshot = (next: TokenSnapshot): void => { + if (!writeSnapshot(next)) throw new Error('Unable to persist the browser session safely'); +}; + +const exactCredentials = (left: TokenSnapshot, right: TokenSnapshot): boolean => { + return isSameSession(left, right) && + left.accessToken === right.accessToken && + left.refreshToken === right.refreshToken; +}; + +const exactSnapshot = (left: TokenSnapshot, right: TokenSnapshot): boolean => { + return left.accessToken === right.accessToken && + left.refreshToken === right.refreshToken && + left.principalId === right.principalId && + left.sessionEpoch === right.sessionEpoch; +}; + +const lockedTokenStore: LockedTokenStore = { + getSnapshot: snapshot, + + replaceSession(accessToken: string | null, refreshToken: string | null): void { + if (!accessToken && !refreshToken) { + this.clear(); + return; + } + const principalId = decodePrincipal(accessToken); + if (accessToken && !principalId) throw new Error('The access token does not contain a valid principal'); + persistSnapshot({ + accessToken, + refreshToken, + principalId, + sessionEpoch: createSessionEpoch() + }); + emit(); + }, + + rotateSession(accessToken: string, refreshToken: string, expected: TokenSnapshot): boolean { + const current = snapshot(); + const principalId = decodePrincipal(accessToken); + if ( + !principalId || + principalId !== expected.principalId || + !exactCredentials(current, expected) + ) return false; + persistSnapshot({ + accessToken, + refreshToken, + principalId, + sessionEpoch: expected.sessionEpoch + }); + emit(); + return true; + }, + + clear(): void { + persistSnapshot(emptySnapshot()); + emit(); + }, + + clearIfUnchanged(expected: TokenSnapshot): boolean { + if (!exactSnapshot(snapshot(), expected)) return false; + this.clear(); + return true; + }, + + beginLogout(): string | null { + const refreshToken = snapshot().refreshToken; + if (refreshToken && !enqueuePendingLogout(refreshToken)) { + throw new Error('Unable to preserve this session for server-side revocation'); + } + this.clear(); + return refreshToken; + }, + + completeLogout(refreshToken: string): void { + // A browser can stop after revocation succeeds but before local clearing. + // Remove the queue item only after the matching physical credential is + // gone; a newer login is never touched. + if (persistedRefreshToken() === refreshToken) this.clear(); + if (!removePendingLogout(refreshToken)) { + throw new Error('Unable to remove the completed logout from the durable queue'); + } + } +}; + +const withSessionLock = (task: (locked: LockedTokenStore) => Promise): Promise => { + return withBrowserLock(SESSION_LOCK_NAME, () => task(lockedTokenStore)); +}; + +if (typeof window !== 'undefined') { + window.addEventListener('storage', (event) => { + const target = storage(); + if (!target || event.storageArea !== target) return; + if ( + event.key === SESSION_KEY || + event.key === ACCESS_TOKEN_KEY || + event.key === REFRESH_TOKEN_KEY || + event.key === null + ) emit(); + if ( + event.key === PENDING_LOGOUT_KEY || + event.key?.startsWith(PENDING_LOGOUT_PREFIX) || + event.key === null + ) emitPendingLogouts(); + }); +} + +export const tokenStore = { + getSnapshot: snapshot, + + getAccessToken(): string | null { + return snapshot().accessToken; + }, + + getRefreshToken(): string | null { + return snapshot().refreshToken; + }, + + setAccessToken(token: string | null): Promise { + return withSessionLock(async (locked) => { + const current = locked.getSnapshot(); + if (!token) { + persistSnapshot(normalizeSnapshot({ ...current, accessToken: null })); + emit(); + return; + } + const principalId = decodePrincipal(token); + if (principalId && principalId === current.principalId && current.sessionEpoch) { + persistSnapshot({ ...current, accessToken: token, principalId }); + emit(); + return; + } + locked.replaceSession(token, null); + }); + }, + + setRefreshToken(token: string | null): Promise { + return withSessionLock(async (locked) => { + const current = locked.getSnapshot(); + if (!current.accessToken && token && !current.sessionEpoch) { + persistSnapshot({ + accessToken: null, + refreshToken: token, + principalId: null, + sessionEpoch: createSessionEpoch() + }); + } else { + persistSnapshot(normalizeSnapshot({ ...current, refreshToken: token })); + } + emit(); + }); + }, + + /** Starts a new login lineage under the browser-wide mutation lock. */ + replaceSession(accessToken: string | null, refreshToken: string | null): Promise { + return withSessionLock(async (locked) => locked.replaceSession(accessToken, refreshToken)); + }, + + /** Rotates only if the exact originating lineage still owns storage. */ + rotateSession(accessToken: string, refreshToken: string, expected: TokenSnapshot): Promise { + return withSessionLock(async (locked) => locked.rotateSession(accessToken, refreshToken, expected)); + }, + + /** Compatibility alias: a complete pair represents a new authentication event. */ + setTokens(accessToken: string | null, refreshToken: string | null): Promise { + return this.replaceSession(accessToken, refreshToken); + }, + + clear(): Promise { + return withSessionLock(async (locked) => locked.clear()); + }, + + clearIfUnchanged(expected: TokenSnapshot): Promise { + return withSessionLock(async (locked) => locked.clearIfUnchanged(expected)); + }, + + beginLogout(): Promise { + return withSessionLock(async (locked) => locked.beginLogout()); + }, + + getPendingLogoutTokens(): string[] { + return [...new Set(readPendingLogouts().map((entry) => entry.refreshToken))]; + }, + + getPendingLogoutToken(): string | null { + return readPendingLogouts()[0]?.refreshToken || null; + }, + + completeLogout(refreshToken: string): Promise { + return withSessionLock(async (locked) => locked.completeLogout(refreshToken)); + }, + + isSameSession, + withSessionLock, + + withLogoutDrainLock(task: () => Promise): Promise { + return withBrowserLock(LOGOUT_DRAIN_LOCK_NAME, task); + }, + + subscribe(listener: TokenListener): () => void { + listeners.add(listener); + return () => listeners.delete(listener); + }, + + subscribePendingLogouts(listener: PendingLogoutListener): () => void { + pendingLogoutListeners.add(listener); + return () => pendingLogoutListeners.delete(listener); + } +}; diff --git a/interviewhub-lld/src/shared/api/types.ts b/interviewhub-lld/src/shared/api/types.ts new file mode 100644 index 00000000..d77bd728 --- /dev/null +++ b/interviewhub-lld/src/shared/api/types.ts @@ -0,0 +1,25 @@ +export interface ApiResult { + success: boolean; + data?: T; + error?: string; + message?: string; + status: number; +} + +export interface ApiEnvelope { + success: boolean; + data?: T; + error?: string; + message?: string; +} + +export interface ApiRequestOptions extends Omit { + signal?: AbortSignal; + timeoutMs?: number; + retryUnauthorized?: boolean; +} + +export interface TokenPair { + token: string; + refreshToken?: string; +} diff --git a/interviewhub-lld/src/types/index.ts b/interviewhub-lld/src/types/index.ts index 2905d482..5ea8dd74 100644 --- a/interviewhub-lld/src/types/index.ts +++ b/interviewhub-lld/src/types/index.ts @@ -33,6 +33,8 @@ export interface Problem { classDiagramMermaid: string; starterFiles: Record; testCases: TestCase[]; + createdAt?: string; + updatedAt?: string; } export interface FileNode { diff --git a/interviewhub-lld/src/vite-env.d.ts b/interviewhub-lld/src/vite-env.d.ts new file mode 100644 index 00000000..11f02fe2 --- /dev/null +++ b/interviewhub-lld/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/interviewhub-lld/verify_auth.mjs b/interviewhub-lld/verify_auth.mjs deleted file mode 100644 index e9d5f04e..00000000 --- a/interviewhub-lld/verify_auth.mjs +++ /dev/null @@ -1,95 +0,0 @@ -import puppeteer from 'puppeteer'; - -async function verifyAuth() { - console.log('🚀 Launching headless browser to verify JudgeMyCode Auth Page at http://localhost:3000...'); - - const browser = await puppeteer.launch({ - headless: 'new', - args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage'] - }); - - const page = await browser.newPage(); - await page.setViewport({ width: 1440, height: 900 }); - - const errors = []; - page.on('console', (msg) => { - if (msg.type() === 'error') { - errors.push(`Console Error: ${msg.text()}`); - } - }); - - page.on('pageerror', (err) => { - errors.push(`Uncaught Page Error: ${err.toString()}`); - }); - - // Navigate to app - const res = await page.goto('http://localhost:3000', { waitUntil: 'networkidle0', timeout: 30000 }); - console.log(`📡 Navigation status: ${res.status()}`); - - // Clear any existing session in localStorage - await page.evaluate(() => { - localStorage.removeItem('judgemycode_user'); - }); - - // Click Sign In button in TopNav or trigger auth view - const signInButton = await page.$('button ::-p-text(Sign In)'); - if (signInButton) { - await signInButton.click(); - } else { - await page.evaluate(() => { - const btn = Array.from(document.querySelectorAll('button')).find(b => b.textContent && b.textContent.includes('Sign In')); - if (btn) btn.click(); - }); - } - - await new Promise(r => setTimeout(r, 1200)); - - // Take screenshot of Auth Page - const screenshotPath = '/Users/tabishmalik/.gemini/antigravity/brain/cd0f9538-a31e-4f34-8e9c-eb4ba87cd39e/auth_page_verified.png'; - await page.screenshot({ path: screenshotPath, fullPage: false }); - console.log(`📸 Screenshot successfully saved to: ${screenshotPath}`); - - // Verify feature text presence on screen - const pageContent = await page.content(); - const hasTitle = pageContent.includes('Master System Design'); - const hasBrand = pageContent.includes('JudgeMyCode'); - const hasFeatures = pageContent.includes('Real Interview Experience') && pageContent.includes('Curated Problems'); - - console.log(`🔍 Content verification: Brand=${hasBrand}, Title=${hasTitle}, Features=${hasFeatures}`); - - // Test Tab Switching to Sign Up - const signUpTab = await page.$('button ::-p-text(Sign Up)'); - if (signUpTab) { - await signUpTab.click(); - await new Promise(r => setTimeout(r, 400)); - } - - // Switch back to Sign In - const signInTab = await page.$('button ::-p-text(Sign In)'); - if (signInTab) { - await signInTab.click(); - await new Promise(r => setTimeout(r, 400)); - } - - // Test guest bypass - const guestBtn = await page.$('button ::-p-text(Enter Workspace as Guest)'); - if (guestBtn) { - await guestBtn.click(); - await new Promise(r => setTimeout(r, 800)); - } - - await browser.close(); - - if (errors.length > 0) { - console.error('❌ Errors detected during page execution:'); - errors.forEach(e => console.error(` - ${e}`)); - process.exit(1); - } else { - console.log('✅ BROWSER VERIFICATION PASSED: Clean console, zero runtime errors!'); - } -} - -verifyAuth().catch((err) => { - console.error('Test execution failed:', err); - process.exit(1); -}); diff --git a/interviewhub-lld/verify_browser.cjs b/interviewhub-lld/verify_browser.cjs deleted file mode 100644 index afc357d1..00000000 --- a/interviewhub-lld/verify_browser.cjs +++ /dev/null @@ -1,66 +0,0 @@ -const puppeteer = require('puppeteer'); -const path = require('path'); - -(async () => { - console.log('🚀 Launching headless browser to verify InterviewHub frontend at http://localhost:3000...'); - - const browser = await puppeteer.launch({ - headless: true, - args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage'] - }); - - const page = await browser.newPage(); - await page.setViewport({ width: 1440, height: 900 }); - - const errors = []; - const logs = []; - - page.on('console', msg => { - const text = msg.text(); - const type = msg.type(); - logs.push(`[${type}] ${text}`); - if (type === 'error') { - errors.push(`Console Error: ${text}`); - } - }); - - page.on('pageerror', err => { - errors.push(`Uncaught Page Error: ${err.toString()}`); - }); - - try { - const response = await page.goto('http://localhost:3000', { - waitUntil: 'networkidle0', - timeout: 20000 - }); - - console.log(`📡 Navigation status: ${response ? response.status() : 'N/A'}`); - - // Wait 2 seconds for all Monaco and React components to stabilize - await new Promise(r => setTimeout(r, 2000)); - - // Capture screenshot to artifact directory - const screenshotPath = '/Users/tabishmalik/.gemini/antigravity/brain/cd0f9538-a31e-4f34-8e9c-eb4ba87cd39e/browser_verified_ui.png'; - await page.screenshot({ path: screenshotPath, fullPage: false }); - console.log(`📸 Screenshot saved to: ${screenshotPath}`); - - // Test clicking tabs in bottom console if available - const tabButtons = await page.$$('button'); - console.log(`🔍 Total interactive button elements found: ${tabButtons.length}`); - - // Output any errors captured - if (errors.length > 0) { - console.error('❌ Errors detected during page execution:'); - errors.forEach(e => console.error(' - ' + e)); - process.exit(1); - } else { - console.log('✅ Browser verification SUCCEEDED with 0 runtime errors or uncaught exceptions!'); - await browser.close(); - process.exit(0); - } - } catch (err) { - console.error('❌ Exception in browser test runner:', err); - await browser.close(); - process.exit(1); - } -})(); diff --git a/interviewhub-lld/verify_browser.mjs b/interviewhub-lld/verify_browser.mjs deleted file mode 100644 index e3594d0f..00000000 --- a/interviewhub-lld/verify_browser.mjs +++ /dev/null @@ -1,66 +0,0 @@ -import puppeteer from 'puppeteer'; - -(async () => { - console.log('🚀 Launching headless browser to verify InterviewHub frontend at http://localhost:3000...'); - - const browser = await puppeteer.launch({ - headless: true, - args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage'] - }); - - const page = await browser.newPage(); - await page.setViewport({ width: 1440, height: 900 }); - - const errors = []; - const logs = []; - - page.on('console', msg => { - const text = msg.text(); - const type = msg.type(); - logs.push(`[${type}] ${text}`); - if (type === 'error') { - errors.push(`Console Error: ${text}`); - } - }); - - page.on('pageerror', err => { - errors.push(`Uncaught Page Error: ${err.toString()}`); - }); - - try { - const response = await page.goto('http://localhost:3000', { - waitUntil: 'networkidle0', - timeout: 25000 - }); - - console.log(`📡 Navigation status: ${response ? response.status() : 'N/A'}`); - - // Wait 3 seconds for Monaco Editor and all React components to mount - await new Promise(r => setTimeout(r, 3000)); - - // Capture screenshot to artifact directory - const screenshotPath = '/Users/tabishmalik/.gemini/antigravity/brain/cd0f9538-a31e-4f34-8e9c-eb4ba87cd39e/browser_verified_ui.png'; - await page.screenshot({ path: screenshotPath, fullPage: false }); - console.log(`📸 Screenshot successfully saved to: ${screenshotPath}`); - - // Count UI elements - const tabButtons = await page.$$('button'); - console.log(`🔍 Total interactive button elements detected: ${tabButtons.length}`); - - // Check for any errors - if (errors.length > 0) { - console.error('❌ Errors detected during page execution:'); - errors.forEach(e => console.error(' - ' + e)); - await browser.close(); - process.exit(1); - } else { - console.log('✅ BROWSER VERIFICATION PASSED: Clean console, zero runtime errors!'); - await browser.close(); - process.exit(0); - } - } catch (err) { - console.error('❌ Exception in browser test runner:', err); - await browser.close(); - process.exit(1); - } -})(); diff --git a/interviewhub-lld/verify_interactive_browser.mjs b/interviewhub-lld/verify_interactive_browser.mjs deleted file mode 100644 index 1a5f65a8..00000000 --- a/interviewhub-lld/verify_interactive_browser.mjs +++ /dev/null @@ -1,108 +0,0 @@ -import puppeteer from 'puppeteer'; - -(async () => { - console.log('🧪 Starting interactive browser flow verification on http://localhost:3000...'); - - const browser = await puppeteer.launch({ - headless: true, - args: ['--no-sandbox', '--disable-setuid-sandbox'] - }); - - const page = await browser.newPage(); - await page.setViewport({ width: 1440, height: 900 }); - - const errors = []; - - page.on('pageerror', err => { - errors.push(`Page Error: ${err.message}`); - }); - - page.on('console', msg => { - if (msg.type() === 'error') { - errors.push(`Console Error: ${msg.text()}`); - } - }); - - try { - await page.goto('http://localhost:3000', { waitUntil: 'networkidle0' }); - console.log(' ✓ Page loaded successfully.'); - - // 1. Click "Test Results" tab in BottomConsole - const testResultsTab = await page.evaluate(() => { - const buttons = Array.from(document.querySelectorAll('button')); - const tab = buttons.find(b => b.textContent?.includes('Test Results')); - if (tab) { - tab.click(); - return true; - } - return false; - }); - console.log(` ✓ Clicked "Test Results" tab: ${testResultsTab}`); - - // Wait a bit - await new Promise(r => setTimeout(r, 1000)); - - // 2. Click "Run All Tests" - const ranAllTests = await page.evaluate(() => { - const buttons = Array.from(document.querySelectorAll('button')); - const runBtn = buttons.find(b => b.textContent?.includes('Run All Tests')); - if (runBtn) { - runBtn.click(); - return true; - } - return false; - }); - console.log(` ✓ Triggered "Run All Tests": ${ranAllTests}`); - - // Wait for backend execution & animation - await new Promise(r => setTimeout(r, 2000)); - - // 3. Switch to "Admin Studio" - const openedAdmin = await page.evaluate(() => { - const buttons = Array.from(document.querySelectorAll('button')); - const adminBtn = buttons.find(b => b.textContent?.includes('Admin Studio')); - if (adminBtn) { - adminBtn.click(); - return true; - } - return false; - }); - console.log(` ✓ Opened "Admin Studio": ${openedAdmin}`); - - await new Promise(r => setTimeout(r, 1500)); - - // Take screenshot of Admin Studio - const adminScreenshotPath = '/Users/tabishmalik/.gemini/antigravity/brain/cd0f9538-a31e-4f34-8e9c-eb4ba87cd39e/admin_studio_verified.png'; - await page.screenshot({ path: adminScreenshotPath }); - console.log(` ✓ Captured Admin Studio screenshot: ${adminScreenshotPath}`); - - // 4. Return back to IDE - const backToIde = await page.evaluate(() => { - const buttons = Array.from(document.querySelectorAll('button')); - const backBtn = buttons.find(b => b.textContent?.includes('Back to Editor')); - if (backBtn) { - backBtn.click(); - return true; - } - return false; - }); - console.log(` ✓ Returned to Editor: ${backToIde}`); - - await new Promise(r => setTimeout(r, 1000)); - - if (errors.length > 0) { - console.error('❌ Errors captured during interactive flow:'); - errors.forEach(e => console.error(' - ' + e)); - await browser.close(); - process.exit(1); - } else { - console.log('🎉 ALL INTERACTIVE BROWSER VERIFICATIONS PASSED WITH 0 ERRORS!'); - await browser.close(); - process.exit(0); - } - } catch (err) { - console.error('❌ Exception in interactive verification:', err); - await browser.close(); - process.exit(1); - } -})(); diff --git a/interviewhub-lld/vite.config.ts b/interviewhub-lld/vite.config.ts index 23728af8..213f0805 100644 --- a/interviewhub-lld/vite.config.ts +++ b/interviewhub-lld/vite.config.ts @@ -4,6 +4,24 @@ import react from '@vitejs/plugin-react'; // https://vitejs.dev/config/ export default defineConfig({ plugins: [react()], + build: { + rollupOptions: { + output: { + manualChunks(id) { + if (id.includes('/node_modules/react/') || id.includes('/node_modules/react-dom/')) { + return 'react-vendor'; + } + if (id.includes('/node_modules/lucide-react/')) { + return 'icons'; + } + if (id.includes('/node_modules/canvas-confetti/')) { + return 'confetti'; + } + return undefined; + } + } + } + }, server: { port: 5173, open: false diff --git a/run-local.sh b/run-local.sh index 39f73f4f..ae66e63b 100755 --- a/run-local.sh +++ b/run-local.sh @@ -1,67 +1,506 @@ #!/usr/bin/env bash -set -euo pipefail +set -Eeuo pipefail -# Simple local runner for InterviewHub backend + frontend (macOS / Linux) -# - creates .env.local defaults if missing -# - starts backend (Go) and frontend (Vite) in background -# - writes PID files and logs to ./logs +IFS=$'\n\t' ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" BACKEND_DIR="$ROOT/interviewhub-backend-go" FRONTEND_DIR="$ROOT/interviewhub-lld" +COMPOSE_FILE="$BACKEND_DIR/docker-compose.yml" +BACKEND_ENV="$BACKEND_DIR/.env.local" +FRONTEND_ENV="$FRONTEND_DIR/.env.local" +RUNTIME_DIR="$ROOT/.local-run" +LIFECYCLE_LOCK_DIR="$RUNTIME_DIR/lifecycle.lock" LOG_DIR="$ROOT/logs" -mkdir -p "$LOG_DIR" +FRONTEND_LOCK_MARKER="$RUNTIME_DIR/frontend-package-lock.cksum" +BACKEND_BINARY="$RUNTIME_DIR/bin/interviewhub-api" +BACKEND_PID_FILE="$ROOT/backend.pid" +FRONTEND_PID_FILE="$ROOT/frontend.pid" -echo "Using workspace: $ROOT" +SETUP_ONLY=0 +POSTGRES_STARTED=0 +BACKEND_STARTED=0 +FRONTEND_STARTED=0 +RUN_SUCCEEDED=0 +LIFECYCLE_LOCK_ACQUIRED=0 -# Create backend env file (if missing) -if [ ! -f "$BACKEND_DIR/.env.local" ]; then - cat > "$BACKEND_DIR/.env.local" < "$FRONTEND_DIR/.env.local" <&2 +} + +die() { + printf '[local] ERROR: %s\n' "$*" >&2 + exit 1 +} + +release_lifecycle_lock() { + [[ "$LIFECYCLE_LOCK_ACQUIRED" -eq 1 ]] || return 0 + rm -f "$LIFECYCLE_LOCK_DIR/pid" + rmdir "$LIFECYCLE_LOCK_DIR" 2>/dev/null || true + LIFECYCLE_LOCK_ACQUIRED=0 +} + +acquire_lifecycle_lock() { + local owner="" + if ! mkdir "$LIFECYCLE_LOCK_DIR" 2>/dev/null; then + if [[ -f "$LIFECYCLE_LOCK_DIR/pid" ]]; then + owner="$(tr -d '[:space:]' <"$LIFECYCLE_LOCK_DIR/pid")" + fi + if [[ "$owner" =~ ^[0-9]+$ ]] && kill -0 "$owner" 2>/dev/null; then + die "Another local lifecycle command is running (PID $owner)" + fi + warn "Recovering a stale local lifecycle lock" + rm -f "$LIFECYCLE_LOCK_DIR/pid" + rmdir "$LIFECYCLE_LOCK_DIR" 2>/dev/null || die "Unable to recover $LIFECYCLE_LOCK_DIR" + mkdir "$LIFECYCLE_LOCK_DIR" || die "Unable to acquire $LIFECYCLE_LOCK_DIR" + fi + printf '%s\n' "$$" >"$LIFECYCLE_LOCK_DIR/pid" + LIFECYCLE_LOCK_ACQUIRED=1 +} + +for arg in "$@"; do + case "$arg" in + --setup-only) SETUP_ONLY=1 ;; + -h|--help) + usage + exit 0 + ;; + *) + usage >&2 + die "Unknown option: $arg" + ;; + esac +done + +require_command() { + command -v "$1" >/dev/null 2>&1 || die "$1 is required but was not found in PATH" +} + +compose() { + docker compose --file "$COMPOSE_FILE" --project-directory "$BACKEND_DIR" "$@" +} + +has_env_key() { + local file="$1" + local key="$2" + [[ -f "$file" ]] && grep -Eq "^[[:space:]]*${key}=" "$file" +} + +ensure_env_key() { + local file="$1" + local key="$2" + local value="$3" + + mkdir -p "$(dirname "$file")" + if ! has_env_key "$file" "$key"; then + printf '\n%s=%s\n' "$key" "$value" >>"$file" + log "Added $key to ${file#"$ROOT/"}" + fi +} + +generate_local_jwt_secret() { + if command -v openssl >/dev/null 2>&1; then + openssl rand -hex 32 + else + printf 'local-development-only-jwt-secret-change-before-sharing-2026' + fi +} + +process_command() { + ps -ww -p "$1" -o command= 2>/dev/null || true +} + +process_cwd() { + local pid="$1" + local cwd="" + + if [[ -L "/proc/$pid/cwd" ]]; then + readlink "/proc/$pid/cwd" 2>/dev/null || true + return + fi + if command -v lsof >/dev/null 2>&1; then + cwd="$(lsof -a -p "$pid" -d cwd -Fn 2>/dev/null | sed -n 's/^n//p' | head -n 1)" + fi + printf '%s\n' "$cwd" +} + +is_owned_process() { + local service="$1" + local pid="$2" + local command_line + local cwd + local relative_vite="node ./node_modules/.bin/vite" + local absolute_vite="node $FRONTEND_DIR/node_modules/.bin/vite" + local direct_vite="$FRONTEND_DIR/node_modules/.bin/vite" + local absolute_node_vite_suffix="/node $FRONTEND_DIR/node_modules/.bin/vite" + + [[ "$pid" =~ ^[0-9]+$ ]] || return 1 + kill -0 "$pid" 2>/dev/null || return 1 + command_line="$(process_command "$pid")" + cwd="$(process_cwd "$pid")" + + case "$service" in + backend) + if [[ "$command_line" == "$BACKEND_BINARY" || "$command_line" == "$BACKEND_BINARY "* ]]; then + return 0 + fi + [[ "$cwd" == "$BACKEND_DIR" ]] || return 1 + [[ "$command_line" == *"go run ./cmd/server"* || "$command_line" == *"/go-build"*"/exe/server"* ]] + ;; + frontend) + [[ "$cwd" == "$FRONTEND_DIR" ]] || return 1 + [[ "$command_line" == "$relative_vite" || + "$command_line" == "$relative_vite "* || + "$command_line" == "$absolute_vite" || + "$command_line" == "$absolute_vite "* || + "$command_line" == "$direct_vite" || + "$command_line" == "$direct_vite "* || + "$command_line" == *"$absolute_node_vite_suffix" || + "$command_line" == *"$absolute_node_vite_suffix "* || + "$command_line" == "npm run dev" || + "$command_line" == "npm run dev "* ]] + ;; + *) return 1 ;; + esac +} + +listener_pid() { + local port="$1" + lsof -nP -tiTCP:"$port" -sTCP:LISTEN 2>/dev/null | head -n 1 || true +} + +write_pid_file() { + local file="$1" + local pid="$2" + printf '%s\n' "$pid" >"$file.tmp" + mv "$file.tmp" "$file" +} + +SERVICE_ALREADY_RUNNING=0 +inspect_service() { + local service="$1" + local pid_file="$2" + local port="$3" + local pid="" + + SERVICE_ALREADY_RUNNING=0 + if [[ -f "$pid_file" ]]; then + pid="$(tr -d '[:space:]' <"$pid_file")" + if [[ ! "$pid" =~ ^[0-9]+$ ]]; then + warn "Removing malformed ${pid_file#"$ROOT/"}" + rm -f "$pid_file" + elif kill -0 "$pid" 2>/dev/null; then + if is_owned_process "$service" "$pid"; then + SERVICE_ALREADY_RUNNING=1 + log "$service is already running (PID $pid)" + return + fi + warn "$pid_file points to live, unrelated PID $pid; removing only the stale PID file" + rm -f "$pid_file" + else + rm -f "$pid_file" + fi + fi + + pid="$(listener_pid "$port")" + if [[ -n "$pid" ]]; then + is_owned_process "$service" "$pid" || die "Port $port is already owned by an unrelated process (PID $pid)" + write_pid_file "$pid_file" "$pid" + SERVICE_ALREADY_RUNNING=1 + log "Adopted existing $service listener on port $port (PID $pid)" + fi +} + +wait_for_http() { + local name="$1" + local url="$2" + local log_file="$3" + local service="$4" + local pid_file="$5" + local port="$6" + local attempts=60 + local attempt + local pid="" + local listening_pid="" -# Ensure required tools are available -command -v go >/dev/null 2>&1 || { echo "Go is required but not installed. Aborting."; exit 1; } -command -v npm >/dev/null 2>&1 || { echo "npm is required but not installed. Aborting."; exit 1; } + for ((attempt = 1; attempt <= attempts; attempt++)); do + if [[ ! -f "$pid_file" ]]; then + warn "$name lost its PID file during startup" + break + fi + pid="$(tr -d '[:space:]' <"$pid_file")" + if ! is_owned_process "$service" "$pid"; then + warn "$name process exited or changed identity during startup" + break + fi + if curl --fail --silent --show-error --max-time 2 "$url" >/dev/null 2>&1; then + listening_pid="$(listener_pid "$port")" + if [[ -n "$listening_pid" ]] && is_owned_process "$service" "$listening_pid"; then + log "$name is ready: $url" + return 0 + fi + warn "$name HTTP probe succeeded, but the listener on port $port is not the managed process" + break + fi + sleep 0.5 + done + + warn "$name did not become ready; recent log output follows" + if [[ -f "$log_file" ]]; then + tail -n 40 "$log_file" >&2 || true + fi + return 1 +} + +wait_for_postgres() { + local attempts=60 + local attempt + local postgres_user="${POSTGRES_USER:-judgemycode}" + local postgres_db="${POSTGRES_DB:-judgemycode}" + for ((attempt = 1; attempt <= attempts; attempt++)); do + if compose exec -T postgres pg_isready -U "$postgres_user" -d "$postgres_db" >/dev/null 2>&1; then + log "PostgreSQL is accepting connections" + return 0 + fi + sleep 0.5 + done + compose logs --tail 40 postgres >&2 || true + return 1 +} + +stop_started_process() { + local service="$1" + local pid_file="$2" + local pid="" + + [[ -f "$pid_file" ]] || return 0 + pid="$(tr -d '[:space:]' <"$pid_file")" + if is_owned_process "$service" "$pid"; then + kill -TERM "$pid" 2>/dev/null || true + for _ in {1..20}; do + kill -0 "$pid" 2>/dev/null || break + sleep 0.25 + done + if kill -0 "$pid" 2>/dev/null; then + kill -KILL "$pid" 2>/dev/null || true + fi + fi + rm -f "$pid_file" +} + +cleanup_on_exit() { + local status=$? + trap - EXIT INT TERM + if [[ "$RUN_SUCCEEDED" -eq 0 ]]; then + warn "Setup/start failed; rolling back services started by this invocation" + if [[ "$FRONTEND_STARTED" -eq 1 ]]; then + stop_started_process frontend "$FRONTEND_PID_FILE" + fi + if [[ "$BACKEND_STARTED" -eq 1 ]]; then + stop_started_process backend "$BACKEND_PID_FILE" + fi + if [[ "$POSTGRES_STARTED" -eq 1 ]]; then + compose stop postgres >/dev/null 2>&1 || true + fi + fi + release_lifecycle_lock + exit "$status" +} + +trap cleanup_on_exit EXIT +trap 'exit 130' INT +trap 'exit 143' TERM + +require_command go +require_command npm +require_command docker +require_command curl +require_command ps +require_command lsof +require_command cksum +mkdir -p "$LOG_DIR" "$RUNTIME_DIR/bin" +acquire_lifecycle_lock +docker compose version >/dev/null 2>&1 || die "Docker Compose v2 is required" +docker info >/dev/null 2>&1 || die "Docker is installed but its daemon is not available" + +touch "$BACKEND_ENV" "$FRONTEND_ENV" + +ensure_env_key "$BACKEND_ENV" HOST "127.0.0.1" +ensure_env_key "$BACKEND_ENV" PORT "5050" +if ! has_env_key "$BACKEND_ENV" JWT_SECRET; then + ensure_env_key "$BACKEND_ENV" JWT_SECRET "$(generate_local_jwt_secret)" +fi +ensure_env_key "$BACKEND_ENV" APP_ENV "development" +ensure_env_key "$BACKEND_ENV" DATABASE_URL "postgres://judgemycode:local-dev-password@127.0.0.1:5432/judgemycode?sslmode=disable" +ensure_env_key "$BACKEND_ENV" ALLOWED_ORIGINS "http://127.0.0.1:5173,http://localhost:5173" +ensure_env_key "$BACKEND_ENV" TRUSTED_PROXY_CIDRS "" +ensure_env_key "$BACKEND_ENV" FRONTEND_URL "http://127.0.0.1:5173" +ensure_env_key "$BACKEND_ENV" BACKEND_URL "http://127.0.0.1:5050" +ensure_env_key "$BACKEND_ENV" EXECUTION_ENABLED "true" +ensure_env_key "$BACKEND_ENV" EXECUTION_RUNNER "local" +ensure_env_key "$BACKEND_ENV" EXECUTION_QUEUE_CAPACITY "32" +ensure_env_key "$BACKEND_ENV" EXECUTION_WORKER_COUNT "2" +ensure_env_key "$BACKEND_ENV" AI_API_URL "" +ensure_env_key "$BACKEND_ENV" AI_API_KEY "" +ensure_env_key "$BACKEND_ENV" AI_MODEL "" +ensure_env_key "$BACKEND_ENV" AI_REQUEST_TIMEOUT "20s" +ensure_env_key "$BACKEND_ENV" AI_MAX_OUTPUT_TOKENS "700" +ensure_env_key "$BACKEND_ENV" AI_EXTERNAL_GUARD "false" +ensure_env_key "$FRONTEND_ENV" VITE_API_BASE_URL "http://127.0.0.1:5050/api" -# Start backend -echo "Starting backend (Go)..." -cd "$BACKEND_DIR" -# Export env vars from .env.local for the process set -a -if [ -f .env.local ]; then source .env.local; fi +# shellcheck disable=SC1090 +source "$BACKEND_ENV" set +a -nohup go run ./cmd/server > "$LOG_DIR/backend.log" 2>&1 & -echo $! > "$ROOT/backend.pid" -echo "Backend PID $(cat $ROOT/backend.pid) — logs: $LOG_DIR/backend.log" - -# Start frontend -echo "Starting frontend (Vite)..." -cd "$FRONTEND_DIR" -if [ ! -d node_modules ]; then - echo "Installing frontend dependencies (npm ci)..." - npm ci + +LOCAL_BIND_HOST="${HOST:-127.0.0.1}" +LOCAL_BACKEND_PORT="${PORT:-5050}" +LOCAL_FRONTEND_HOST="127.0.0.1" +LOCAL_FRONTEND_PORT="${FRONTEND_PORT:-5173}" +LOCAL_POSTGRES_PORT="${POSTGRES_PORT:-5432}" +LOCAL_POSTGRES_DB="${POSTGRES_DB:-judgemycode}" +LOCAL_POSTGRES_USER="${POSTGRES_USER:-judgemycode}" + +validate_port() { + local name="$1" + local value="$2" + local numeric + [[ "$value" =~ ^[0-9]+$ ]] || die "$name must be numeric" + ((${#value} <= 5)) || die "$name must be between 1 and 65535" + numeric=$((10#$value)) + ((numeric >= 1 && numeric <= 65535)) || die "$name must be between 1 and 65535" +} + +validate_port PORT "$LOCAL_BACKEND_PORT" +validate_port FRONTEND_PORT "$LOCAL_FRONTEND_PORT" +validate_port POSTGRES_PORT "$LOCAL_POSTGRES_PORT" +[[ "$LOCAL_BACKEND_PORT" != "$LOCAL_FRONTEND_PORT" ]] || die "PORT and FRONTEND_PORT must be different" +[[ "$LOCAL_BACKEND_PORT" != "$LOCAL_POSTGRES_PORT" ]] || die "PORT and POSTGRES_PORT must be different" +[[ "$LOCAL_FRONTEND_PORT" != "$LOCAL_POSTGRES_PORT" ]] || die "FRONTEND_PORT and POSTGRES_PORT must be different" +[[ "$LOCAL_BIND_HOST" == "127.0.0.1" || "$LOCAL_BIND_HOST" == "localhost" ]] || die "The local workflow requires HOST=127.0.0.1 or localhost to avoid exposing the development runner" +[[ "${APP_ENV:-development}" == "development" ]] || die "The local workflow requires APP_ENV=development" + +database_target="${DATABASE_URL%%\?*}" +case "$database_target" in + postgres://"$LOCAL_POSTGRES_USER":*@127.0.0.1:"$LOCAL_POSTGRES_PORT"/"$LOCAL_POSTGRES_DB" | \ + postgres://"$LOCAL_POSTGRES_USER":*@localhost:"$LOCAL_POSTGRES_PORT"/"$LOCAL_POSTGRES_DB" | \ + postgresql://"$LOCAL_POSTGRES_USER":*@127.0.0.1:"$LOCAL_POSTGRES_PORT"/"$LOCAL_POSTGRES_DB" | \ + postgresql://"$LOCAL_POSTGRES_USER":*@localhost:"$LOCAL_POSTGRES_PORT"/"$LOCAL_POSTGRES_DB") ;; + *) die "Refusing to seed DATABASE_URL because it is not the loopback Compose database (${LOCAL_POSTGRES_USER}@127.0.0.1:${LOCAL_POSTGRES_PORT}/${LOCAL_POSTGRES_DB})" ;; +esac + +if compose ps --status running --services 2>/dev/null | grep -qx postgres; then + log "PostgreSQL Compose service is already running; reconciling its configuration" +else + POSTGRES_STARTED=1 fi -nohup npm run dev > "$LOG_DIR/frontend.log" 2>&1 & -echo $! > "$ROOT/frontend.pid" -echo "Frontend PID $(cat $ROOT/frontend.pid) — logs: $LOG_DIR/frontend.log" +# Reconcile the project before inspecting host ports so containers from the +# former api/worker/redis Compose topology cannot block the new host processes. +compose up -d --remove-orphans postgres +wait_for_postgres -echo "\nAll launched." -echo "Frontend: http://localhost:5173 (Vite)" -echo "Backend API base: http://localhost:5050/api" -echo "Tail logs with: tail -f $LOG_DIR/backend.log $LOG_DIR/frontend.log" +BACKEND_ALREADY_RUNNING=0 +FRONTEND_ALREADY_RUNNING=0 +if [[ "$SETUP_ONLY" -eq 0 ]]; then + inspect_service backend "$BACKEND_PID_FILE" "$LOCAL_BACKEND_PORT" + BACKEND_ALREADY_RUNNING="$SERVICE_ALREADY_RUNNING" + inspect_service frontend "$FRONTEND_PID_FILE" "$LOCAL_FRONTEND_PORT" + FRONTEND_ALREADY_RUNNING="$SERVICE_ALREADY_RUNNING" + + # A repeated full-stack launch is a deterministic reconciliation: rebuild + # the API and restart both host processes so source/config changes cannot be + # hidden behind an adopted stale binary. The stop script independently + # validates every target before signalling it. + if [[ "$BACKEND_ALREADY_RUNNING" -eq 1 || "$FRONTEND_ALREADY_RUNNING" -eq 1 ]]; then + log "Restarting the existing local application processes" + JMC_LIFECYCLE_LOCK_HELD=1 "$ROOT/stop-local.sh" + BACKEND_ALREADY_RUNNING=0 + FRONTEND_ALREADY_RUNNING=0 + fi +fi + +log "Applying migrations and reconciling local demo users/problem seeds" +(cd "$BACKEND_DIR" && ./scripts/seed-db.sh --users --sync-problems) + +if [[ "$FRONTEND_ALREADY_RUNNING" -eq 0 ]]; then + frontend_lock_fingerprint="$(cksum "$FRONTEND_DIR/package-lock.json")" + installed_lock_fingerprint="" + if [[ -f "$FRONTEND_LOCK_MARKER" ]]; then + installed_lock_fingerprint="$(cat "$FRONTEND_LOCK_MARKER")" + fi + if [[ ! -d "$FRONTEND_DIR/node_modules" ]] || [[ "$installed_lock_fingerprint" != "$frontend_lock_fingerprint" ]] || ! (cd "$FRONTEND_DIR" && npm ls --depth=0 >/dev/null 2>&1); then + log "Installing frontend dependencies with npm ci" + (cd "$FRONTEND_DIR" && npm ci --no-audit --no-fund) + printf '%s\n' "$frontend_lock_fingerprint" >"$FRONTEND_LOCK_MARKER.tmp" + mv "$FRONTEND_LOCK_MARKER.tmp" "$FRONTEND_LOCK_MARKER" + else + log "Frontend dependencies are already installed" + fi +fi + +if [[ "$SETUP_ONLY" -eq 1 ]]; then + RUN_SUCCEEDED=1 + log "Setup complete. Run ./run-local.sh or make dev to start the application." + exit 0 +fi + +if [[ "$BACKEND_ALREADY_RUNNING" -eq 0 ]]; then + log "Building the backend" + (cd "$BACKEND_DIR" && go build -trimpath -o "$BACKEND_BINARY" ./cmd/server) + printf '\n[%s] starting backend\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" >>"$LOG_DIR/backend.log" + ( + cd "$BACKEND_DIR" + set -a + # shellcheck disable=SC1091 + source .env.local + set +a + export BACKEND_URL="http://$LOCAL_BIND_HOST:$LOCAL_BACKEND_PORT" + export FRONTEND_URL="http://$LOCAL_FRONTEND_HOST:$LOCAL_FRONTEND_PORT" + export ALLOWED_ORIGINS="http://$LOCAL_FRONTEND_HOST:$LOCAL_FRONTEND_PORT,http://localhost:$LOCAL_FRONTEND_PORT" + exec nohup "$BACKEND_BINARY" + ) >>"$LOG_DIR/backend.log" 2>&1 & + backend_pid=$! + write_pid_file "$BACKEND_PID_FILE" "$backend_pid" + BACKEND_STARTED=1 + sleep 0.2 + kill -0 "$backend_pid" 2>/dev/null || die "Backend exited during startup; see logs/backend.log" + log "Started backend (PID $backend_pid)" +fi +wait_for_http "Backend readiness" "http://$LOCAL_BIND_HOST:$LOCAL_BACKEND_PORT/api/health/ready" "$LOG_DIR/backend.log" backend "$BACKEND_PID_FILE" "$LOCAL_BACKEND_PORT" + +if [[ "$FRONTEND_ALREADY_RUNNING" -eq 0 ]]; then + printf '\n[%s] starting frontend\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" >>"$LOG_DIR/frontend.log" + ( + cd "$FRONTEND_DIR" + export VITE_API_BASE_URL="http://$LOCAL_BIND_HOST:$LOCAL_BACKEND_PORT/api" + exec nohup ./node_modules/.bin/vite --host "$LOCAL_FRONTEND_HOST" --port "$LOCAL_FRONTEND_PORT" --strictPort + ) >>"$LOG_DIR/frontend.log" 2>&1 & + frontend_pid=$! + write_pid_file "$FRONTEND_PID_FILE" "$frontend_pid" + FRONTEND_STARTED=1 + sleep 0.2 + kill -0 "$frontend_pid" 2>/dev/null || die "Frontend exited during startup; see logs/frontend.log" + log "Started frontend (PID $frontend_pid)" +fi +wait_for_http "Frontend" "http://$LOCAL_FRONTEND_HOST:$LOCAL_FRONTEND_PORT/" "$LOG_DIR/frontend.log" frontend "$FRONTEND_PID_FILE" "$LOCAL_FRONTEND_PORT" -trap 'echo "Run-local interrupted"; exit 0' SIGINT SIGTERM +RUN_SUCCEEDED=1 +printf '\nJudgeMyCode is ready.\n' +printf ' Frontend: %s\n' "http://$LOCAL_FRONTEND_HOST:$LOCAL_FRONTEND_PORT" +printf ' Backend: %s\n' "http://$LOCAL_BIND_HOST:$LOCAL_BACKEND_PORT/api" +printf ' Logs: %s\n' "$LOG_DIR" +printf ' Stop: ./stop-local.sh\n' +warn "Local execution runs untrusted submissions on the host. Use only with trusted local code." diff --git a/stop-local.sh b/stop-local.sh index a1565b97..03c65b5b 100755 --- a/stop-local.sh +++ b/stop-local.sh @@ -1,26 +1,291 @@ #!/usr/bin/env bash -set -euo pipefail +set -Eeuo pipefail + +IFS=$'\n\t' ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +BACKEND_DIR="$ROOT/interviewhub-backend-go" +FRONTEND_DIR="$ROOT/interviewhub-lld" +COMPOSE_FILE="$BACKEND_DIR/docker-compose.yml" +RUNTIME_DIR="$ROOT/.local-run" +LIFECYCLE_LOCK_DIR="$RUNTIME_DIR/lifecycle.lock" +BACKEND_BINARY="$RUNTIME_DIR/bin/interviewhub-api" +BACKEND_PID_FILE="$ROOT/backend.pid" +FRONTEND_PID_FILE="$ROOT/frontend.pid" +BACKEND_ENV="$BACKEND_DIR/.env.local" + +if [[ -f "$BACKEND_ENV" ]]; then + set -a + # shellcheck disable=SC1090 + source "$BACKEND_ENV" + set +a +fi + +BACKEND_PORT="${PORT:-5050}" +FRONTEND_PORT="${FRONTEND_PORT:-5173}" +STOP_DEPENDENCIES=0 +STOP_ERRORS=0 +LIFECYCLE_LOCK_ACQUIRED=0 + +usage() { + cat <<'EOF' +Usage: ./stop-local.sh [--dependencies|--all] + + --dependencies, --all + Also stop the local PostgreSQL Compose service. The named + database volume is preserved. +EOF +} -echo "Stopping local services..." +log() { + printf '[stop] %s\n' "$*" +} -if [ -f "$ROOT/backend.pid" ]; then - PID=$(cat "$ROOT/backend.pid") - if kill -0 "$PID" 2>/dev/null; then - echo "Killing backend PID $PID" - kill "$PID" || true +warn() { + printf '[stop] WARNING: %s\n' "$*" >&2 +} + +die() { + printf '[stop] ERROR: %s\n' "$*" >&2 + exit 1 +} + +release_lifecycle_lock() { + [[ "$LIFECYCLE_LOCK_ACQUIRED" -eq 1 ]] || return 0 + rm -f "$LIFECYCLE_LOCK_DIR/pid" + rmdir "$LIFECYCLE_LOCK_DIR" 2>/dev/null || true + LIFECYCLE_LOCK_ACQUIRED=0 +} + +acquire_lifecycle_lock() { + local owner="" + mkdir -p "$RUNTIME_DIR" + if ! mkdir "$LIFECYCLE_LOCK_DIR" 2>/dev/null; then + if [[ -f "$LIFECYCLE_LOCK_DIR/pid" ]]; then + owner="$(tr -d '[:space:]' <"$LIFECYCLE_LOCK_DIR/pid")" + fi + if [[ "$owner" =~ ^[0-9]+$ ]] && kill -0 "$owner" 2>/dev/null; then + die "Another local lifecycle command is running (PID $owner)" + fi + warn "Recovering a stale local lifecycle lock" + rm -f "$LIFECYCLE_LOCK_DIR/pid" + rmdir "$LIFECYCLE_LOCK_DIR" 2>/dev/null || die "Unable to recover $LIFECYCLE_LOCK_DIR" + mkdir "$LIFECYCLE_LOCK_DIR" || die "Unable to acquire $LIFECYCLE_LOCK_DIR" fi - rm -f "$ROOT/backend.pid" + printf '%s\n' "$$" >"$LIFECYCLE_LOCK_DIR/pid" + LIFECYCLE_LOCK_ACQUIRED=1 +} + +for arg in "$@"; do + case "$arg" in + --dependencies|--all) STOP_DEPENDENCIES=1 ;; + -h|--help) + usage + exit 0 + ;; + *) + usage >&2 + printf '[stop] ERROR: Unknown option: %s\n' "$arg" >&2 + exit 2 + ;; + esac +done + +if [[ "${JMC_LIFECYCLE_LOCK_HELD:-0}" != "1" ]]; then + acquire_lifecycle_lock + trap release_lifecycle_lock EXIT fi -if [ -f "$ROOT/frontend.pid" ]; then - PID=$(cat "$ROOT/frontend.pid") - if kill -0 "$PID" 2>/dev/null; then - echo "Killing frontend PID $PID" - kill "$PID" || true +compose() { + docker compose --file "$COMPOSE_FILE" --project-directory "$BACKEND_DIR" "$@" +} + +process_command() { + ps -ww -p "$1" -o command= 2>/dev/null || true +} + +process_cwd() { + local pid="$1" + local cwd="" + + if [[ -L "/proc/$pid/cwd" ]]; then + readlink "/proc/$pid/cwd" 2>/dev/null || true + return fi - rm -f "$ROOT/frontend.pid" + if command -v lsof >/dev/null 2>&1; then + cwd="$(lsof -a -p "$pid" -d cwd -Fn 2>/dev/null | sed -n 's/^n//p' | head -n 1)" + fi + printf '%s\n' "$cwd" +} + +is_owned_process() { + local service="$1" + local pid="$2" + local command_line + local cwd + local relative_vite="node ./node_modules/.bin/vite" + local absolute_vite="node $FRONTEND_DIR/node_modules/.bin/vite" + local direct_vite="$FRONTEND_DIR/node_modules/.bin/vite" + local absolute_node_vite_suffix="/node $FRONTEND_DIR/node_modules/.bin/vite" + + [[ "$pid" =~ ^[0-9]+$ ]] || return 1 + kill -0 "$pid" 2>/dev/null || return 1 + command_line="$(process_command "$pid")" + cwd="$(process_cwd "$pid")" + + case "$service" in + backend) + if [[ "$command_line" == "$BACKEND_BINARY" || "$command_line" == "$BACKEND_BINARY "* ]]; then + return 0 + fi + [[ "$cwd" == "$BACKEND_DIR" ]] || return 1 + [[ "$command_line" == *"go run ./cmd/server"* || "$command_line" == *"/go-build"*"/exe/server"* ]] + ;; + frontend) + [[ "$cwd" == "$FRONTEND_DIR" ]] || return 1 + [[ "$command_line" == "$relative_vite" || + "$command_line" == "$relative_vite "* || + "$command_line" == "$absolute_vite" || + "$command_line" == "$absolute_vite "* || + "$command_line" == "$direct_vite" || + "$command_line" == "$direct_vite "* || + "$command_line" == *"$absolute_node_vite_suffix" || + "$command_line" == *"$absolute_node_vite_suffix "* || + "$command_line" == "npm run dev" || + "$command_line" == "npm run dev "* ]] + ;; + *) return 1 ;; + esac +} + +child_pids() { + local parent="$1" + if command -v pgrep >/dev/null 2>&1; then + pgrep -P "$parent" 2>/dev/null || true + else + ps -eo pid=,ppid= | awk -v parent="$parent" '$2 == parent { print $1 }' + fi +} + +PROCESS_TREE=() +collect_process_tree() { + local parent="$1" + local child + while IFS= read -r child; do + [[ -n "$child" ]] || continue + collect_process_tree "$child" + done < <(child_pids "$parent") + PROCESS_TREE+=("$parent") +} + +terminate_tree() { + local service="$1" + local root_pid="$2" + local pid + local attempt + local still_running=0 + + is_owned_process "$service" "$root_pid" || return 1 + PROCESS_TREE=() + collect_process_tree "$root_pid" + + log "Stopping $service process tree rooted at PID $root_pid" + for pid in "${PROCESS_TREE[@]}"; do + kill -TERM "$pid" 2>/dev/null || true + done + + for ((attempt = 1; attempt <= 40; attempt++)); do + still_running=0 + for pid in "${PROCESS_TREE[@]}"; do + if kill -0 "$pid" 2>/dev/null; then + still_running=1 + break + fi + done + [[ "$still_running" -eq 0 ]] && return 0 + sleep 0.25 + done + + warn "$service did not stop within 10 seconds; killing only its validated process tree" + for pid in "${PROCESS_TREE[@]}"; do + kill -KILL "$pid" 2>/dev/null || true + done +} + +stop_from_pid_file() { + local service="$1" + local pid_file="$2" + local pid="" + + [[ -f "$pid_file" ]] || return 0 + pid="$(tr -d '[:space:]' <"$pid_file")" + if [[ ! "$pid" =~ ^[0-9]+$ ]]; then + warn "Removing malformed ${pid_file#"$ROOT/"}" + rm -f "$pid_file" + return 0 + fi + if ! kill -0 "$pid" 2>/dev/null; then + log "Removing stale ${pid_file#"$ROOT/"}" + rm -f "$pid_file" + return 0 + fi + if ! is_owned_process "$service" "$pid"; then + warn "$pid_file points to live, unrelated PID $pid; removing only the stale PID file" + rm -f "$pid_file" + return 0 + fi + + terminate_tree "$service" "$pid" || true + rm -f "$pid_file" +} + +listener_pids() { + local port="$1" + if command -v lsof >/dev/null 2>&1; then + lsof -nP -tiTCP:"$port" -sTCP:LISTEN 2>/dev/null || true + fi +} + +stop_owned_listeners() { + local service="$1" + local port="$2" + local pid + + if ! command -v lsof >/dev/null 2>&1; then + warn "lsof is unavailable; unable to check port $port for orphaned $service listeners" + STOP_ERRORS=1 + return 0 + fi + + while IFS= read -r pid; do + [[ -n "$pid" ]] || continue + if is_owned_process "$service" "$pid"; then + terminate_tree "$service" "$pid" || true + else + warn "Port $port is owned by unrelated PID $pid; it was not signalled" + STOP_ERRORS=1 + fi + done < <(listener_pids "$port") +} + +stop_from_pid_file frontend "$FRONTEND_PID_FILE" +stop_owned_listeners frontend "$FRONTEND_PORT" +stop_from_pid_file backend "$BACKEND_PID_FILE" +stop_owned_listeners backend "$BACKEND_PORT" + +if [[ "$STOP_DEPENDENCIES" -eq 1 ]]; then + if command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1; then + log "Stopping PostgreSQL and reconciling Compose orphans; the named volume will be preserved" + compose down --remove-orphans + else + warn "Docker Compose is unavailable; PostgreSQL was not stopped" + STOP_ERRORS=1 + fi +fi + +if [[ "$STOP_ERRORS" -ne 0 ]]; then + warn "One or more unrelated or unverifiable processes were left untouched" + exit 1 fi -echo "Stopped. You may also remove logs in $ROOT/logs if desired." +log "Local application services stopped"