diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..7862e5c --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,22 @@ +version: 2 +updates: + - package-ecosystem: "nuget" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 10 + labels: + - "dependencies" + - "security" + groups: + efcore: + patterns: + - "Microsoft.EntityFrameworkCore*" + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + labels: + - "dependencies" + - "ci" diff --git a/.github/workflows/ContinuousIntegration.yml b/.github/workflows/ContinuousIntegration.yml new file mode 100644 index 0000000..bfbaaec --- /dev/null +++ b/.github/workflows/ContinuousIntegration.yml @@ -0,0 +1,51 @@ +name: ContinuousIntegration + +on: + push: + branches: [main] + pull_request: + branches: [main] + +permissions: + contents: read + +jobs: + build-and-test: + runs-on: ubuntu-latest + steps: + - name: Checkout repository source + uses: actions/checkout@v4 + + - name: Setup .NET SDK + uses: actions/setup-dotnet@v4 + with: + dotnet-version: "10.0.x" + + - name: Restore NuGet packages + run: dotnet restore + + - name: Build application + run: dotnet build --no-restore --configuration Release + + - name: Run automated tests + run: dotnet test --no-build --configuration Release --logger "trx;LogFileName=test-results.trx" + + - name: Publish test result artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-results + path: "**/test-results.trx" + + # Defense-in-depth alongside Dependabot: fail the build if a known + # critical/high vulnerable NuGet package is referenced (direct or transitive). + - name: Scan for vulnerable NuGet packages + run: | + set -euo pipefail + echo "Scanning for known-vulnerable packages..." + report=$(dotnet list package --vulnerable --include-transitive 2>&1) || true + echo "$report" + if echo "$report" | grep -qi "has the following vulnerable packages"; then + echo "::error::Vulnerable NuGet packages detected. See job log above." + exit 1 + fi diff --git a/.github/workflows/DependencyVulnerabilityScanning.yml b/.github/workflows/DependencyVulnerabilityScanning.yml new file mode 100644 index 0000000..341abd8 --- /dev/null +++ b/.github/workflows/DependencyVulnerabilityScanning.yml @@ -0,0 +1,77 @@ +name: DependencyVulnerabilityScanning + +# Enforces a remediation SLA policy (documented in docs/SecurityHardening.md) +# on top of Dependabot itself: Critical/High severity alerts must be +# remediated within 7 days, Medium within 30. This does not replace +# Dependabot (which finds the CVEs and opens PRs) -- it just makes the SLA +# visible and failing in CI instead of only living in a doc nobody re-reads. + +on: + schedule: + - cron: "0 8 * * *" + workflow_dispatch: {} + +permissions: + contents: read + security-events: read + +env: + CRITICAL_HIGH_SLA_DAYS: 7 + MEDIUM_SLA_DAYS: 30 + +jobs: + check-sla: + runs-on: ubuntu-latest + steps: + - name: Check Dependabot alerts against remediation SLA + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + run: | + set -euo pipefail + + alerts=$(gh api "repos/$REPO/dependabot/alerts?state=open&per_page=100" --paginate 2>/dev/null || echo "[]") + + echo "## Dependabot SLA check for $REPO" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + + count=$(echo "$alerts" | jq 'length') + if [ "$count" -eq 0 ]; then + echo "No open Dependabot alerts. Nothing to check." >> "$GITHUB_STEP_SUMMARY" + echo "No open Dependabot alerts." + exit 0 + fi + + now_epoch=$(date -u +%s) + breached=0 + + echo "| Severity | Package | Opened | Age (days) | SLA (days) | Status |" >> "$GITHUB_STEP_SUMMARY" + echo "|---|---|---|---|---|---|" >> "$GITHUB_STEP_SUMMARY" + + while IFS=$'\t' read -r severity package created_at html_url; do + created_epoch=$(date -u -d "$created_at" +%s 2>/dev/null || echo "$now_epoch") + age_days=$(( (now_epoch - created_epoch) / 86400 )) + + case "$severity" in + critical|high) sla=$CRITICAL_HIGH_SLA_DAYS ;; + medium) sla=$MEDIUM_SLA_DAYS ;; + *) sla="" ;; + esac + + if [ -z "$sla" ]; then + status="ℹ️ no SLA (low)" + elif [ "$age_days" -gt "$sla" ]; then + status="❌ BREACHED" + breached=1 + else + status="✅ within SLA" + fi + + echo "| $severity | [$package]($html_url) | $created_at | $age_days | ${sla:-n/a} | $status |" >> "$GITHUB_STEP_SUMMARY" + done < <(echo "$alerts" | jq -r '.[] | [.security_advisory.severity, .dependency.package.name, .created_at, .html_url] | @tsv') + + if [ "$breached" -eq 1 ]; then + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "::error::One or more Critical/High Dependabot alerts have exceeded the ${CRITICAL_HIGH_SLA_DAYS}-day remediation SLA. See job summary for details." + exit 1 + fi diff --git a/.github/workflows/EnforceRequiredReviewer.yml b/.github/workflows/EnforceRequiredReviewer.yml new file mode 100644 index 0000000..aaff681 --- /dev/null +++ b/.github/workflows/EnforceRequiredReviewer.yml @@ -0,0 +1,63 @@ +name: EnforceRequiredReviewer + +on: + workflow_dispatch: + push: + branches: + - main + - develop + paths: + - .github/workflows/EnforceRequiredReviewer.yml + +permissions: + contents: read + +jobs: + enforce-required-reviewer: + runs-on: ubuntu-latest + steps: + - name: Enforce required reviewer protection on protected branches + env: + GH_TOKEN: ${{ secrets.GH_ADMIN_TOKEN || secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + run: | + set -euo pipefail + + for branch in main develop; do + echo "Applying branch protection to $branch" + + if ! gh api "repos/$REPO/branches/$branch" >/dev/null 2>&1; then + echo "Branch '$branch' was not found or is not accessible; skipping." + continue + fi + + body='{ + "required_status_checks": null, + "enforce_admins": true, + "required_pull_request_reviews": { + "required_approving_review_count": 1, + "dismiss_stale_reviews": true, + "require_code_owner_reviews": false, + "require_last_push_approval": false + }, + "restrictions": null, + "allow_force_pushes": false, + "allow_deletions": false, + "block_creations": false, + "required_linear_history": false, + "allow_fork_syncing": false, + "required_conversation_resolution": false, + "lock_branch": false + }' + + if ! printf '%s\n' "$body" | gh api \ + --method PUT \ + -H "Accept: application/vnd.github+json" \ + "/repos/$REPO/branches/$branch/protection" \ + --input -; then + echo "::error::Unable to update branch protection for '$branch'. The token likely lacks admin access to repository branch protection APIs. Configure a PAT or GitHub App token with repo admin permissions and set it as GH_ADMIN_TOKEN." + exit 1 + fi + + echo "Branch protection updated successfully for $branch" + done diff --git a/.github/workflows/SecretScanning.yml b/.github/workflows/SecretScanning.yml new file mode 100644 index 0000000..66458d4 --- /dev/null +++ b/.github/workflows/SecretScanning.yml @@ -0,0 +1,60 @@ +name: SecretScanning + +on: + push: + branches: [main] + pull_request: + branches: [main] + schedule: + # Weekly full-history scan to catch anything that slipped past PR scanning + # (e.g. force-pushed history, commits made outside the protected branch). + - cron: "0 6 * * 1" + workflow_dispatch: {} + +permissions: + contents: read + +jobs: + diff-scan: + if: github.event_name == 'push' || github.event_name == 'pull_request' + runs-on: ubuntu-latest + steps: + - name: Checkout repository with history + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Run TruffleHog filesystem scan + run: | + docker run --rm -v "$PWD:/repo" -w /repo trufflesecurity/trufflehog:latest \ + filesystem /repo --fail --only-verified --json > trufflehog-diff-report.json + + full-history-scan: + if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + steps: + - name: Checkout full repository history + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Scan full git history for committed secrets + run: | + docker run --rm -v "$PWD:/repo" -w /repo trufflesecurity/trufflehog:latest \ + git file:///repo --fail --only-verified --json > trufflehog-history-report.json + + - name: Upload TruffleHog history report + if: always() + uses: actions/upload-artifact@v4 + with: + name: trufflehog-history-report + path: trufflehog-history-report.json + + - name: Report remediation guidance on failure + if: failure() + run: | + echo "::error::TruffleHog found credentials in git history. Do NOT just delete the commit." + echo "1. Rotate/revoke the exposed credential immediately at the issuing provider." + echo "2. Purge it from history with git filter-repo or BFG Repo-Cleaner." + echo "3. Force-push the rewritten history and have all collaborators re-clone." + echo "See trufflehog-history-report.json (uploaded as a workflow artifact) for exact commits/files." diff --git a/.github/workflows/SecurityComplianceAudit.yml b/.github/workflows/SecurityComplianceAudit.yml new file mode 100644 index 0000000..e919129 --- /dev/null +++ b/.github/workflows/SecurityComplianceAudit.yml @@ -0,0 +1,85 @@ +name: SecurityComplianceAudit + +on: + schedule: + - cron: "0 7 * * 1" + workflow_dispatch: {} + +permissions: + contents: read + +jobs: + audit: + runs-on: ubuntu-latest + steps: + - name: Checkout repository source + uses: actions/checkout@v4 + + - name: Audit repository security hardening settings + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + run: | + set +e + fail=0 + echo "## Security compliance audit for $REPO" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + + check() { + local name="$1" ok="$2" detail="$3" + if [ "$ok" = "true" ]; then + echo "- ✅ $name" >> "$GITHUB_STEP_SUMMARY" + else + echo "- ❌ $name — $detail" >> "$GITHUB_STEP_SUMMARY" + fail=1 + fi + } + + protection=$(gh api "repos/$REPO/branches/main/protection" 2>/dev/null || true) + if [ -z "$protection" ]; then + check "Branch protection enabled on main" false "no protection rule found (or token lacks admin:repo)" + else + check "Branch protection enabled on main" true "" + + reviewers=$(echo "$protection" | jq -r '.required_pull_request_reviews.required_approving_review_count // 0' 2>/dev/null || echo 0) + if [ "$reviewers" -ge 1 ]; then + check "Required PR reviewers >= 1" true "" + else + check "Required PR reviewers >= 1" false "currently $reviewers" + fi + + enforce_admins=$(echo "$protection" | jq -r '.enforce_admins.enabled // false' 2>/dev/null || echo false) + check "Branch protection enforced for admins" "$enforce_admins" "enforce_admins is false" + fi + + signatures=$(gh api "repos/$REPO/branches/main/protection/required_signatures" 2>/dev/null | jq -r '.enabled // false' 2>/dev/null || echo false) + check "Required signed commits on main" "$signatures" "required_signatures.enabled is false or unset" + + alerts_status=$(gh api "repos/$REPO/vulnerability-alerts" -i 2>/dev/null | head -1 | grep -o '204' || true) + if [ "$alerts_status" = "204" ]; then + check "Dependabot vulnerability alerts enabled" true "" + else + check "Dependabot vulnerability alerts enabled" false "vulnerability-alerts endpoint did not return 204" + fi + + fixes_status=$(gh api "repos/$REPO/automated-security-fixes" -i 2>/dev/null | head -1 | grep -o '200' || true) + if [ -n "$fixes_status" ]; then + check "Dependabot automated security fixes enabled" true "" + else + check "Dependabot automated security fixes enabled" false "automated-security-fixes endpoint did not return 200" + fi + + { + echo "" + echo "### Not checkable from a repo-scoped workflow token" + echo "- ⚠️ **SSO enforcement** — GitHub Enterprise org-level setting. Verify manually: Organization settings → Authentication security → \"Require SAML SSO\"." + echo "- ⚠️ **Audit log retention** — GitHub Enterprise Cloud org/enterprise setting. Verify manually: Enterprise settings → Audit log → retention policy." + } >> "$GITHUB_STEP_SUMMARY" + + if [ "$fail" -ne 0 ]; then + echo "::warning::Security audit found one or more missing or non-compliant settings. Review the summary above." + else + echo "::notice::Security audit completed successfully." + fi + + exit 0 diff --git a/.gitignore b/.gitignore index 7282dbf..de89f6f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,57 +1,36 @@ -## A streamlined .gitignore for modern .NET projects -## including temporary files, build results, and -## files generated by popular .NET tools. If you are -## developing with Visual Studio, the VS .gitignore -## https://github.com/github/gitignore/blob/main/VisualStudio.gitignore -## has more thorough IDE-specific entries. -## -## Get latest from https://github.com/github/gitignore/blob/main/Dotnet.gitignore - # Build results -[Dd]ebug/ -[Dd]ebugPublic/ -[Rr]elease/ -[Rr]eleases/ -x64/ -x86/ -[Ww][Ii][Nn]32/ -[Aa][Rr][Mm]/ -[Aa][Rr][Mm]64/ -bld/ -[Bb]in/ -[Oo]bj/ -[Ll]og/ -[Ll]ogs/ - -# .NET Core -project.lock.json -project.fragment.lock.json -artifacts/ - -# ASP.NET Scaffolding -ScaffoldingReadMe.txt +bin/ +obj/ +out/ -# NuGet Packages -*.nupkg -# NuGet Symbol Packages -*.snupkg +# Rider / VS / VS Code +.vs/ +.vscode/ +*.user +*.suo -# dotenv environment variables file +# Test results +[Tt]est[Rr]esult*/ +*.trx +*.coverage +*.coveragexml + +# Local SQLite databases +*.db +*.db-shm +*.db-wal + +# User-specific / local secrets +appsettings.*.local.json +*.local.json .env +.env.* -# Others -~$* -*~ -CodeCoverage/ - -# MSBuild Binary and Structured Log -*.binlog +# Rider +.idea/ -# MSTest test Results -[Tt]est[Rr]esult*/ -[Bb]uild[Ll]og.* +# macOS +.DS_Store -# NUnit -*.VisualState.xml -TestResult.xml -nunit-*.xml +# Claude Code local (machine-specific) settings +.claude/settings.local.json diff --git a/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 0000000..900a0e0 --- /dev/null +++ b/.gitleaks.toml @@ -0,0 +1,14 @@ +title = "Sm_API gitleaks config" + +[extend] +useDefault = true + +[allowlist] +description = "Known-safe test/dev fixtures, not real credentials" +paths = [ + '''appsettings\.Development\.json''', +] +regexes = [ + # EF Core local SQLite connection strings are not secrets. + '''Data Source=.*\.db''', +] diff --git a/README.md b/README.md new file mode 100644 index 0000000..671ed7b --- /dev/null +++ b/README.md @@ -0,0 +1,77 @@ +# Sm_API — School Management System API + +A .NET 10 Web API with full CRUD operations for a simple school management +domain (students, teachers, classrooms, enrollments), plus a hardened GitHub +Actions pipeline covering secret scanning, dependency vulnerability scanning, +and a repo security-compliance audit. + +## Solution layout + +``` +src/Sm_API.Api/ ASP.NET Core Web API (controllers, EF Core + SQLite) +tests/Sm_API.Tests/ xUnit + WebApplicationFactory integration tests +.github/ CI, gitleaks, and compliance-audit workflows + Dependabot +scripts/ One-time gh-CLI repo hardening script +docs/ Security hardening reference (see docs/SECURITY-HARDENING.md) +``` + +## Domain model + +- **Student** — Id, FirstName, LastName, Email (unique), DateOfBirth, EnrollmentDate +- **Teacher** — Id, FirstName, LastName, Email (unique), Subject, HireDate +- **ClassRoom** — Id, Name, GradeLevel, RoomNumber, TeacherId (FK) +- **Enrollment** — Id, StudentId (FK), ClassRoomId (FK), EnrollmentDate — join between Student and ClassRoom + +Each entity has a controller exposing: + +``` +GET /api/{resource} +GET /api/{resource}/{id} +POST /api/{resource} +PUT /api/{resource}/{id} +DELETE /api/{resource}/{id} +``` + +Resources: `students`, `teachers`, `classrooms`, `enrollments`. + +## Running locally + +```bash +dotnet restore +dotnet build +dotnet run --project src/Sm_API.Api +``` + +The API applies EF Core migrations automatically on startup and uses a local +SQLite file (`smapi.db`, gitignored) by default. In development, OpenAPI JSON +is available at `/openapi/v1.json`. + +## Running tests + +```bash +dotnet test +``` + +Integration tests spin up the full app via `WebApplicationFactory` +against a real in-memory SQLite connection (not the EF InMemory provider), so +constraints and cascade behavior match production. + +## Security hardening + +See [docs/SECURITY-HARDENING.md](docs/SECURITY-HARDENING.md) for the full +mapping of branch protection / PR reviews / signed commits / SSO / audit logs +/ secret scanning / dependency scanning to what's automated here vs. what +requires a GitHub org/enterprise owner to configure manually. + +After first pushing this repo to GitHub: + +```powershell +./scripts/setup-github-security.ps1 -Repo "your-org/Sm_API" +``` + +## Building a frontend against this API + +See [docs/API-CONTEXT.md](docs/API-CONTEXT.md) — a self-contained reference +(endpoints, JSON shapes, validation/error behavior, CORS config) intended to +be handed to whoever (or whatever) builds the React UI, without needing to +read the C# source first. diff --git a/Sm_API.slnx b/Sm_API.slnx new file mode 100644 index 0000000..9f94fe4 --- /dev/null +++ b/Sm_API.slnx @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/docs/API-CONTEXT.md b/docs/API-CONTEXT.md new file mode 100644 index 0000000..85c08ec --- /dev/null +++ b/docs/API-CONTEXT.md @@ -0,0 +1,214 @@ +# Sm_API — Context for building the React frontend + +This file is a self-contained reference for implementing a React UI against +the Sm_API backend. It documents the API surface, exact JSON shapes, error +behavior, and relationships needed to build CRUD screens without having to +read the C# source. + +## Stack / how to run the API + +- .NET 10 ASP.NET Core Web API, controller-based, `src/Sm_API.Api` +- EF Core + SQLite (`smapi.db`, auto-migrated on startup) +- JSON casing: **camelCase** (ASP.NET Core default `System.Text.Json` policy) +- Run locally: `dotnet run --project src/Sm_API.Api` → default profile listens + on `https://localhost:7127` and `http://localhost:5140` (see + `src/Sm_API.Api/Properties/launchSettings.json`; confirm against console + output since this can change) +- OpenAPI JSON (machine-readable spec) at `/openapi/v1.json` in Development — + useful for generating a typed client (e.g. `openapi-typescript`, + `orval`, or `openapi-generator-cli`) instead of hand-writing fetch calls. + +### CORS + +The API has a CORS policy (`Program.cs`) allowing browser requests from: +- `http://localhost:5173` (Vite default) +- `http://localhost:3000` (CRA/Next default) + +Configurable via `Cors:AllowedOrigins` in `appsettings.json` — add your dev +server's origin there if it differs. All methods and headers are allowed for +those origins; no credentials/cookies are used (the API has no auth yet). + +### Authentication + +**None currently implemented.** All endpoints are open. If the React app +needs to demo/enforce auth, that will need to be added to the API first +(not in scope of what's built so far). + +## Domain model & relationships + +``` +Teacher 1 ──< ClassRoom >── * Enrollment >── 1 Student +``` + +- A `Teacher` has many `ClassRoom`s (one teacher per classroom). +- A `ClassRoom` has many `Enrollment`s. +- A `Student` has many `Enrollment`s. +- `Enrollment` is the join entity between `Student` and `ClassRoom` (a + student can be enrolled in many classrooms; a classroom has many students). +- A `(StudentId, ClassRoomId)` pair must be unique — a student can't be + enrolled in the same class twice (enforced by a unique DB index, returns + `409 Conflict` on violation). + +### Field reference (JSON shapes, camelCase) + +**Student** +| Field | Type | Notes | +|---|---|---| +| `id` | number | server-generated, omit on create | +| `firstName` | string | required, max 100 | +| `lastName` | string | required, max 100 | +| `email` | string | required, valid email format, max 200, **unique** | +| `dateOfBirth` | string (`YYYY-MM-DD`) | `DateOnly` on the server | +| `enrollmentDate` | string (`YYYY-MM-DD`) | `DateOnly` on the server | + +**Teacher** +| Field | Type | Notes | +|---|---|---| +| `id` | number | server-generated | +| `firstName` | string | required, max 100 | +| `lastName` | string | required, max 100 | +| `email` | string | required, valid email, max 200, **unique** | +| `subject` | string | required, max 100 | +| `hireDate` | string (`YYYY-MM-DD`) | | + +**ClassRoom** +| Field | Type | Notes | +|---|---|---| +| `id` | number | server-generated | +| `name` | string | required, max 100 | +| `gradeLevel` | number | required, 1–12 | +| `roomNumber` | string | required, max 20 | +| `teacherId` | number | required, must reference an existing Teacher | + +**Enrollment** +| Field | Type | Notes | +|---|---|---| +| `id` | number | server-generated | +| `studentId` | number | required, must reference an existing Student | +| `classRoomId` | number | required, must reference an existing ClassRoom | +| `enrollmentDate` | string (`YYYY-MM-DD`) | | + +Note: **write** payloads (POST/PUT) never include `id` — it's server-assigned. +**Read** responses always include `id`. There's no separate "read includes +nested objects" behavior — reads are flat (e.g. a ClassRoom read returns +`teacherId`, not a nested `teacher` object). If the UI needs the teacher's +name next to a classroom, fetch `/api/teachers` separately and join client-side, +or fetch by id. + +## Endpoints + +All four resources follow the identical REST shape. Base path: `/api/{resource}`. +Resources: `students`, `teachers`, `classrooms`, `enrollments`. + +| Method | Path | Body | Success | Notes | +|---|---|---|---|---| +| GET | `/api/{resource}` | — | `200 OK`, array of Read DTOs | | +| GET | `/api/{resource}/{id}` | — | `200 OK`, single Read DTO | `404` if not found | +| POST | `/api/{resource}` | Write DTO (JSON) | `201 Created`, Read DTO, `Location` header | `400` on validation error, `409` on conflict (see below) | +| PUT | `/api/{resource}/{id}` | Write DTO (JSON) | `204 No Content` | `404` if not found, `400`/`409` as above | +| DELETE | `/api/{resource}/{id}` | — | `204 No Content` | `404` if not found; see delete guards below | + +### Example: create a student + +``` +POST /api/students +Content-Type: application/json + +{ + "firstName": "Ada", + "lastName": "Lovelace", + "email": "ada@school.test", + "dateOfBirth": "2012-05-01", + "enrollmentDate": "2024-09-01" +} +``` + +Response `201 Created`: +```json +{ + "id": 1, + "firstName": "Ada", + "lastName": "Lovelace", + "email": "ada@school.test", + "dateOfBirth": "2012-05-01", + "enrollmentDate": "2024-09-01" +} +``` + +### Error responses + +Errors use RFC 7807 `ProblemDetails` shape, e.g.: + +```json +{ + "title": "A student with this email already exists.", + "status": 409 +} +``` + +Validation errors (`400`) from ASP.NET Core's automatic model validation come +back as the standard ASP.NET `ValidationProblemDetails` shape instead, with +an `errors` dictionary keyed by field name: + +```json +{ + "type": "https://tools.ietf.org/html/rfc9110#section-15.5.1", + "title": "One or more validation errors occurred.", + "status": 400, + "errors": { + "Email": ["The Email field is not a valid e-mail address."] + } +} +``` + +### Known 409/400 business rules the UI should handle gracefully + +- Creating/updating a **Student** or **Teacher** with an email that's already + in use → `409`. +- Creating a **ClassRoom** with a `teacherId` that doesn't exist → `400`. +- Creating an **Enrollment** with a `studentId` or `classRoomId` that doesn't + exist → `400`. +- Creating a duplicate **Enrollment** (same student + classroom already + enrolled) → `409`. +- Deleting a **Teacher** who still has classrooms assigned → `409` (delete + or reassign the classrooms first). +- Deleting a **Student** or **ClassRoom** cascades and removes their + **Enrollments** automatically (no guard, unlike Teacher). + +## Suggested React app shape + +A reasonable structure mirroring the four resources: + +``` +src/ + api/ + client.ts # fetch wrapper, base URL from env (VITE_API_BASE_URL) + students.ts # typed CRUD calls for /api/students + teachers.ts + classrooms.ts + enrollments.ts + types/ + models.ts # Student, Teacher, ClassRoom, Enrollment, *WriteDto types + pages/ + students/ (List, Detail/Form) + teachers/ (List, Detail/Form) + classrooms/ (List, Detail/Form — needs a Teacher picker, so fetch teachers list too) + enrollments/ (List, Form — needs Student + ClassRoom pickers) + components/ + DataTable, FormField, ConflictErrorBanner (for surfacing 409s), etc. +``` + +Recommended: generate `types/models.ts` and the fetch client directly from +`/openapi/v1.json` rather than hand-transcribing this table, to avoid drift +as the API evolves — but the table above is accurate as of this API version +and sufficient to hand-write a client if preferred. + +## Verifying frontend integration + +1. `dotnet run --project src/Sm_API.Api` (note the printed URL) +2. Point the React app's API base URL at it (respecting the CORS origins above) +3. Exercise the golden path: create a Teacher → create a ClassRoom for that + Teacher → create a Student → create an Enrollment linking them → confirm + list/detail/edit/delete all round-trip correctly, and that a duplicate + enrollment or unknown foreign key surfaces the `400`/`409` from the API + rather than crashing the UI. diff --git a/docs/SECURITY-HARDENING.md b/docs/SECURITY-HARDENING.md new file mode 100644 index 0000000..6857cf2 --- /dev/null +++ b/docs/SECURITY-HARDENING.md @@ -0,0 +1,92 @@ +# GitHub Security Hardening — Sm_API + +This maps the four hardening requirements to what's implemented here, what's +automated in the pipeline, and what still requires a human with the right +GitHub role to click a button. + +## 1. Harden GitHub organisation/repo settings + +| Control | Status | Where | +|---|---|---| +| Branch protection on `main` | Automated | `scripts/setup-github-security.ps1` (run once after the repo exists on GitHub) | +| Required PR reviews (≥ 1 reviewer) | Automated | Same script, `required_pull_request_reviews.required_approving_review_count` | +| Signed commits required | Automated | Same script, `PUT /repos/:owner/:repo/branches/main/protection/required_signatures` | +| SSO enforcement | **Manual, org-owner action** | GitHub Enterprise only. Org Settings → Authentication security → "Require SAML SSO". Cannot be set via repo-scoped API/token. | +| Audit log retention | **Manual, enterprise-owner action** | GitHub Enterprise Cloud only. Enterprise Settings → Audit log → retention/streaming. Not exposed to any repo-level API. | + +Verified continuously by `.github/workflows/security-compliance-audit.yml`, which +runs weekly (and on demand via `workflow_dispatch`) and reports pass/fail for +everything checkable from a repo token, and explicitly flags SSO/audit-log +retention as unverifiable from CI — see the job summary, not just logs. + +## 2. Enable secret scanning + +Chosen approach: **gitleaks** in GitHub Actions (works on any plan/visibility, +unlike native GitHub Advanced Security secret scanning which needs an +Enterprise license or a public repo). + +- `.github/workflows/gitleaks.yml` — `diff-scan` job runs on every push/PR, + scanning only the changed commits. +- Same workflow's `full-history-scan` job (weekly + manual) runs + `gitleaks detect --log-opts="--all"` over the entire git history — this is + also the mechanism for requirement 3 below. +- `.gitleaks.toml` — baseline config extending gitleaks' default ruleset, with + an allowlist for known-safe local dev fixtures (e.g. SQLite connection + strings, which are not secrets). +- `scripts/setup-github-security.ps1` also attempts to turn on native GitHub + secret scanning + push protection via the repo `security_and_analysis` API + as defense-in-depth; it silently no-ops if the plan doesn't support it. + +## 3. Scan git history for committed credentials + +Same `full-history-scan` job as above satisfies this: it walks **all** commits +(`--log-opts="--all"`), not just the current diff, so it catches secrets that +were committed and later removed in a subsequent commit (which a diff-only +scan would miss). + +If it finds something, the workflow fails loudly with this remediation order +(also printed in the job log): + +1. **Rotate/revoke the credential at the issuing provider first.** A leaked + key is compromised the moment it's pushed, regardless of whether it's later + removed from history. +2. Purge it from git history (`git filter-repo` or BFG Repo-Cleaner). +3. Force-push the rewritten history; have all collaborators re-clone rather + than pull/rebase. + +For a one-off local check before ever pushing (e.g. to also cross-check with +a second tool), run: + +```bash +docker run --rm -v "$PWD:/repo" zricethezav/gitleaks:latest detect --source=/repo --log-opts="--all" +docker run --rm -v "$PWD:/repo" trufflesecurity/trufflehog:latest git file:///repo +``` + +## 4. Dependency vulnerability scanning + +Chosen approach: **Dependabot** (native to GitHub, no external account/token). + +- `.github/dependabot.yml` — weekly scans for both the `nuget` ecosystem + (this solution's packages) and `github-actions` (the workflow files + themselves), opening PRs automatically. +- `scripts/setup-github-security.ps1` enables Dependabot vulnerability alerts + and automated security fixes at the repo level (both are off by default on + some account types). +- `.github/workflows/ci.yml` adds a build-time gate: + `dotnet list package --vulnerable --include-transitive` fails the build if + any referenced package (direct or transitive) has a known advisory. This is + defense-in-depth alongside Dependabot — it catches a vulnerable package the + moment it's introduced in a PR, rather than waiting for the next scheduled + Dependabot run. + +**Remediation SLA policy (recommended, enforce via team process, not code):** +Critical/High severity advisories should be patched or have a tracked +mitigation plan within **7 days**; Medium within **30 days**; Low at the next +regular dependency-update cycle. + +## Verifying this end-to-end + +1. Push this repo to GitHub, then run `./scripts/setup-github-security.ps1 -Repo "owner/Sm_API"`. +2. Open a PR touching any file → confirm `CI` and `Secret Scanning (gitleaks)` checks appear and must pass before merge (branch protection). +3. Manually run the `Security Compliance Audit` workflow (`gh workflow run security-compliance-audit.yml`) → check the job summary. +4. On a throwaway branch, commit a dummy secret pattern (e.g. `AKIAABCDEFGHIJKLMNOP` — a fake AWS-shaped key), push, confirm the PR check fails, then remove it before merging. Do not leave real or fake secrets in `main`. diff --git a/scripts/setup-github-security.ps1 b/scripts/setup-github-security.ps1 new file mode 100644 index 0000000..a9752bf --- /dev/null +++ b/scripts/setup-github-security.ps1 @@ -0,0 +1,105 @@ +<# +.SYNOPSIS + One-time hardening script for a new GitHub repo, using the gh CLI. + +.DESCRIPTION + Run this once after `git push`-ing this repo to GitHub, from a user with + admin rights on the repo. It configures what's controllable at the repo + level: branch protection, required PR review count, required signed + commits, and Dependabot alerts/security updates. + + It does NOT and CANNOT configure SSO enforcement or audit log retention -- + those are GitHub Enterprise org/enterprise-owner settings made in the + browser. Manual steps are printed at the end. + +.PARAMETER Repo + "owner/repo" slug, e.g. "myorg/Sm_API". Defaults to the repo the gh CLI + is currently authenticated against in this directory. + +.PARAMETER RequiredReviewers + Minimum number of required PR approvals. Defaults to 1. + +.EXAMPLE + ./scripts/setup-github-security.ps1 -Repo "myorg/Sm_API" +#> +param( + [string]$Repo, + [int]$RequiredReviewers = 1 +) + +$ErrorActionPreference = "Stop" + +if (-not (Get-Command gh -ErrorAction SilentlyContinue)) { + Write-Error "GitHub CLI ('gh') is not installed or not on PATH. Install from https://cli.github.com/ and run 'gh auth login' first." + exit 1 +} + +if (-not $Repo) { + $Repo = (gh repo view --json nameWithOwner -q .nameWithOwner) + if (-not $Repo) { + Write-Error "Could not determine repo. Pass -Repo 'owner/repo' explicitly." + exit 1 + } +} + +Write-Host "Configuring security hardening for $Repo" -ForegroundColor Cyan + +# --- Branch protection on main: required reviews, required status checks, enforce for admins --- +Write-Host "`n[1/4] Enabling branch protection on main..." -ForegroundColor Yellow + +$protectionBody = @{ + required_status_checks = @{ + strict = $true + contexts = @("build-and-test", "diff-scan") + } + enforce_admins = $true + required_pull_request_reviews = @{ + required_approving_review_count = $RequiredReviewers + dismiss_stale_reviews = $true + } + restrictions = $null + required_linear_history = $true + allow_force_pushes = $false + allow_deletions = $false +} | ConvertTo-Json -Depth 5 + +$protectionBody | gh api ` + --method PUT ` + -H "Accept: application/vnd.github+json" ` + "/repos/$Repo/branches/main/protection" ` + --input - + +# --- Required signed commits --- +Write-Host "`n[2/4] Requiring signed commits on main..." -ForegroundColor Yellow +gh api --method POST -H "Accept: application/vnd.github+json" "/repos/$Repo/branches/main/protection/required_signatures" | Out-Null + +# --- Dependabot vulnerability alerts + automated security fixes --- +Write-Host "`n[3/4] Enabling Dependabot alerts + automated security fixes..." -ForegroundColor Yellow +gh api --method PUT -H "Accept: application/vnd.github+json" "/repos/$Repo/vulnerability-alerts" | Out-Null +gh api --method PUT -H "Accept: application/vnd.github+json" "/repos/$Repo/automated-security-fixes" | Out-Null + +# --- Secret scanning + push protection (only takes effect on GH Advanced Security / public repos) --- +Write-Host "`n[4/4] Attempting to enable native secret scanning (requires GH Advanced Security or a public repo)..." -ForegroundColor Yellow +try { + $secBody = @{ security_and_analysis = @{ secret_scanning = @{ status = "enabled" }; secret_scanning_push_protection = @{ status = "enabled" } } } | ConvertTo-Json -Depth 5 + $secBody | gh api --method PATCH -H "Accept: application/vnd.github+json" "/repos/$Repo" --input - | Out-Null + Write-Host "Native secret scanning request sent (verify it actually took effect in repo Settings -> Code security)." -ForegroundColor Green +} catch { + Write-Host "Could not enable native secret scanning via API -- this repo/plan likely doesn't support GH Advanced Security. gitleaks in Actions is already covering this." -ForegroundColor DarkYellow +} + +Write-Host "`nDone with what's automatable from here." -ForegroundColor Cyan +Write-Host @" + +MANUAL STEPS -- these are org/enterprise-owner actions, not repo settings: + + * SSO enforcement (requires GitHub Enterprise + SAML/OIDC IdP configured): + Org Settings -> Authentication security -> "Require SAML SSO" + https://docs.github.com/en/enterprise-cloud@latest/organizations/managing-saml-single-sign-on-for-your-organization/enforcing-saml-single-sign-on-for-your-organization + + * Audit log retention (GitHub Enterprise Cloud only): + Enterprise Settings -> Audit log -> configure retention / streaming to external storage + https://docs.github.com/en/enterprise-cloud@latest/admin/monitoring-activity-in-your-enterprise/reviewing-audit-logs-for-your-enterprise/about-the-audit-log-for-your-enterprise + +Run scripts/security-compliance-audit workflow (or `gh workflow run security-compliance-audit.yml`) to verify what was actually applied. +"@ -ForegroundColor White diff --git a/src/Sm_API.Api/Controllers/ClassRoomsController.cs b/src/Sm_API.Api/Controllers/ClassRoomsController.cs new file mode 100644 index 0000000..8b2b41d --- /dev/null +++ b/src/Sm_API.Api/Controllers/ClassRoomsController.cs @@ -0,0 +1,94 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Sm_API.Api.Data; +using Sm_API.Api.Dtos; +using Sm_API.Api.Models; + +namespace Sm_API.Api.Controllers; + +[ApiController] +[Route("api/[controller]")] +public class ClassRoomsController : ControllerBase +{ + private readonly SmApiDbContext _db; + + public ClassRoomsController(SmApiDbContext db) + { + _db = db; + } + + private static ClassRoomReadDto ToDto(ClassRoom c) => + new(c.Id, c.Name, c.GradeLevel, c.RoomNumber, c.TeacherId); + + [HttpGet] + public async Task>> GetAll() + { + var classRooms = await _db.ClassRooms.AsNoTracking().ToListAsync(); + return Ok(classRooms.Select(ToDto)); + } + + [HttpGet("{id:int}")] + public async Task> GetById(int id) + { + var classRoom = await _db.ClassRooms.AsNoTracking().FirstOrDefaultAsync(c => c.Id == id); + if (classRoom is null) return NotFound(); + return Ok(ToDto(classRoom)); + } + + [HttpPost] + public async Task> Create(ClassRoomWriteDto dto) + { + var teacherExists = await _db.Teachers.AnyAsync(t => t.Id == dto.TeacherId); + if (!teacherExists) + { + return BadRequest(new ProblemDetails { Title = $"Teacher {dto.TeacherId} does not exist." }); + } + + var classRoom = new ClassRoom + { + Name = dto.Name, + GradeLevel = dto.GradeLevel, + RoomNumber = dto.RoomNumber, + TeacherId = dto.TeacherId + }; + + _db.ClassRooms.Add(classRoom); + await _db.SaveChangesAsync(); + + return CreatedAtAction(nameof(GetById), new { id = classRoom.Id }, ToDto(classRoom)); + } + + [HttpPut("{id:int}")] + public async Task Update(int id, ClassRoomWriteDto dto) + { + var classRoom = await _db.ClassRooms.FindAsync(id); + if (classRoom is null) return NotFound(); + + var teacherExists = await _db.Teachers.AnyAsync(t => t.Id == dto.TeacherId); + if (!teacherExists) + { + return BadRequest(new ProblemDetails { Title = $"Teacher {dto.TeacherId} does not exist." }); + } + + classRoom.Name = dto.Name; + classRoom.GradeLevel = dto.GradeLevel; + classRoom.RoomNumber = dto.RoomNumber; + classRoom.TeacherId = dto.TeacherId; + + await _db.SaveChangesAsync(); + + return NoContent(); + } + + [HttpDelete("{id:int}")] + public async Task Delete(int id) + { + var classRoom = await _db.ClassRooms.FindAsync(id); + if (classRoom is null) return NotFound(); + + _db.ClassRooms.Remove(classRoom); + await _db.SaveChangesAsync(); + + return NoContent(); + } +} diff --git a/src/Sm_API.Api/Controllers/EnrollmentsController.cs b/src/Sm_API.Api/Controllers/EnrollmentsController.cs new file mode 100644 index 0000000..8ab7101 --- /dev/null +++ b/src/Sm_API.Api/Controllers/EnrollmentsController.cs @@ -0,0 +1,107 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Sm_API.Api.Data; +using Sm_API.Api.Dtos; +using Sm_API.Api.Models; + +namespace Sm_API.Api.Controllers; + +[ApiController] +[Route("api/[controller]")] +public class EnrollmentsController : ControllerBase +{ + private readonly SmApiDbContext _db; + + public EnrollmentsController(SmApiDbContext db) + { + _db = db; + } + + private static EnrollmentReadDto ToDto(Enrollment e) => + new(e.Id, e.StudentId, e.ClassRoomId, e.EnrollmentDate); + + [HttpGet] + public async Task>> GetAll() + { + var enrollments = await _db.Enrollments.AsNoTracking().ToListAsync(); + return Ok(enrollments.Select(ToDto)); + } + + [HttpGet("{id:int}")] + public async Task> GetById(int id) + { + var enrollment = await _db.Enrollments.AsNoTracking().FirstOrDefaultAsync(e => e.Id == id); + if (enrollment is null) return NotFound(); + return Ok(ToDto(enrollment)); + } + + [HttpPost] + public async Task> Create(EnrollmentWriteDto dto) + { + var studentExists = await _db.Students.AnyAsync(s => s.Id == dto.StudentId); + if (!studentExists) + { + return BadRequest(new ProblemDetails { Title = $"Student {dto.StudentId} does not exist." }); + } + + var classRoomExists = await _db.ClassRooms.AnyAsync(c => c.Id == dto.ClassRoomId); + if (!classRoomExists) + { + return BadRequest(new ProblemDetails { Title = $"ClassRoom {dto.ClassRoomId} does not exist." }); + } + + var enrollment = new Enrollment + { + StudentId = dto.StudentId, + ClassRoomId = dto.ClassRoomId, + EnrollmentDate = dto.EnrollmentDate + }; + + _db.Enrollments.Add(enrollment); + + try + { + await _db.SaveChangesAsync(); + } + catch (DbUpdateException) + { + return Conflict(new ProblemDetails { Title = "This student is already enrolled in this class." }); + } + + return CreatedAtAction(nameof(GetById), new { id = enrollment.Id }, ToDto(enrollment)); + } + + [HttpPut("{id:int}")] + public async Task Update(int id, EnrollmentWriteDto dto) + { + var enrollment = await _db.Enrollments.FindAsync(id); + if (enrollment is null) return NotFound(); + + enrollment.StudentId = dto.StudentId; + enrollment.ClassRoomId = dto.ClassRoomId; + enrollment.EnrollmentDate = dto.EnrollmentDate; + + try + { + await _db.SaveChangesAsync(); + } + catch (DbUpdateException) + { + return Conflict(new ProblemDetails { Title = "This student is already enrolled in this class." }); + } + + return NoContent(); + } + + [HttpDelete("{id:int}")] + public async Task Delete(int id) + { + var enrollment = await _db.Enrollments.FindAsync(id); + if (enrollment is null) return NotFound(); + + _db.Enrollments.Remove(enrollment); + await _db.SaveChangesAsync(); + + return NoContent(); + } +} diff --git a/src/Sm_API.Api/Controllers/StudentsController.cs b/src/Sm_API.Api/Controllers/StudentsController.cs new file mode 100644 index 0000000..9d5bf05 --- /dev/null +++ b/src/Sm_API.Api/Controllers/StudentsController.cs @@ -0,0 +1,99 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Sm_API.Api.Data; +using Sm_API.Api.Dtos; +using Sm_API.Api.Models; + +namespace Sm_API.Api.Controllers; + +[ApiController] +[Route("api/[controller]")] +public class StudentsController : ControllerBase +{ + private readonly SmApiDbContext _db; + + public StudentsController(SmApiDbContext db) + { + _db = db; + } + + private static StudentReadDto ToDto(Student s) => + new(s.Id, s.FirstName, s.LastName, s.Email, s.DateOfBirth, s.EnrollmentDate); + + [HttpGet] + public async Task>> GetAll() + { + var students = await _db.Students.AsNoTracking().ToListAsync(); + return Ok(students.Select(ToDto)); + } + + [HttpGet("{id:int}")] + public async Task> GetById(int id) + { + var student = await _db.Students.AsNoTracking().FirstOrDefaultAsync(s => s.Id == id); + if (student is null) return NotFound(); + return Ok(ToDto(student)); + } + + [HttpPost] + public async Task> Create(StudentWriteDto dto) + { + var student = new Student + { + FirstName = dto.FirstName, + LastName = dto.LastName, + Email = dto.Email, + DateOfBirth = dto.DateOfBirth, + EnrollmentDate = dto.EnrollmentDate + }; + + _db.Students.Add(student); + + try + { + await _db.SaveChangesAsync(); + } + catch (DbUpdateException) + { + return Conflict(new ProblemDetails { Title = "A student with this email already exists." }); + } + + return CreatedAtAction(nameof(GetById), new { id = student.Id }, ToDto(student)); + } + + [HttpPut("{id:int}")] + public async Task Update(int id, StudentWriteDto dto) + { + var student = await _db.Students.FindAsync(id); + if (student is null) return NotFound(); + + student.FirstName = dto.FirstName; + student.LastName = dto.LastName; + student.Email = dto.Email; + student.DateOfBirth = dto.DateOfBirth; + student.EnrollmentDate = dto.EnrollmentDate; + + try + { + await _db.SaveChangesAsync(); + } + catch (DbUpdateException) + { + return Conflict(new ProblemDetails { Title = "A student with this email already exists." }); + } + + return NoContent(); + } + + [HttpDelete("{id:int}")] + public async Task Delete(int id) + { + var student = await _db.Students.FindAsync(id); + if (student is null) return NotFound(); + + _db.Students.Remove(student); + await _db.SaveChangesAsync(); + + return NoContent(); + } +} diff --git a/src/Sm_API.Api/Controllers/TeachersController.cs b/src/Sm_API.Api/Controllers/TeachersController.cs new file mode 100644 index 0000000..946eaba --- /dev/null +++ b/src/Sm_API.Api/Controllers/TeachersController.cs @@ -0,0 +1,105 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Sm_API.Api.Data; +using Sm_API.Api.Dtos; +using Sm_API.Api.Models; + +namespace Sm_API.Api.Controllers; + +[ApiController] +[Route("api/[controller]")] +public class TeachersController : ControllerBase +{ + private readonly SmApiDbContext _db; + + public TeachersController(SmApiDbContext db) + { + _db = db; + } + + private static TeacherReadDto ToDto(Teacher t) => + new(t.Id, t.FirstName, t.LastName, t.Email, t.Subject, t.HireDate); + + [HttpGet] + public async Task>> GetAll() + { + var teachers = await _db.Teachers.AsNoTracking().ToListAsync(); + return Ok(teachers.Select(ToDto)); + } + + [HttpGet("{id:int}")] + public async Task> GetById(int id) + { + var teacher = await _db.Teachers.AsNoTracking().FirstOrDefaultAsync(t => t.Id == id); + if (teacher is null) return NotFound(); + return Ok(ToDto(teacher)); + } + + [HttpPost] + public async Task> Create(TeacherWriteDto dto) + { + var teacher = new Teacher + { + FirstName = dto.FirstName, + LastName = dto.LastName, + Email = dto.Email, + Subject = dto.Subject, + HireDate = dto.HireDate + }; + + _db.Teachers.Add(teacher); + + try + { + await _db.SaveChangesAsync(); + } + catch (DbUpdateException) + { + return Conflict(new ProblemDetails { Title = "A teacher with this email already exists." }); + } + + return CreatedAtAction(nameof(GetById), new { id = teacher.Id }, ToDto(teacher)); + } + + [HttpPut("{id:int}")] + public async Task Update(int id, TeacherWriteDto dto) + { + var teacher = await _db.Teachers.FindAsync(id); + if (teacher is null) return NotFound(); + + teacher.FirstName = dto.FirstName; + teacher.LastName = dto.LastName; + teacher.Email = dto.Email; + teacher.Subject = dto.Subject; + teacher.HireDate = dto.HireDate; + + try + { + await _db.SaveChangesAsync(); + } + catch (DbUpdateException) + { + return Conflict(new ProblemDetails { Title = "A teacher with this email already exists." }); + } + + return NoContent(); + } + + [HttpDelete("{id:int}")] + public async Task Delete(int id) + { + var teacher = await _db.Teachers.FindAsync(id); + if (teacher is null) return NotFound(); + + var hasClasses = await _db.ClassRooms.AnyAsync(c => c.TeacherId == id); + if (hasClasses) + { + return Conflict(new ProblemDetails { Title = "Cannot delete a teacher who is still assigned to a class." }); + } + + _db.Teachers.Remove(teacher); + await _db.SaveChangesAsync(); + + return NoContent(); + } +} diff --git a/src/Sm_API.Api/Data/Migrations/20260712061224_InitialCreate.Designer.cs b/src/Sm_API.Api/Data/Migrations/20260712061224_InitialCreate.Designer.cs new file mode 100644 index 0000000..97b9de7 --- /dev/null +++ b/src/Sm_API.Api/Data/Migrations/20260712061224_InitialCreate.Designer.cs @@ -0,0 +1,196 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Sm_API.Api.Data; + +#nullable disable + +namespace Sm_API.Api.Data.Migrations +{ + [DbContext(typeof(SmApiDbContext))] + [Migration("20260712061224_InitialCreate")] + partial class InitialCreate + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.9"); + + modelBuilder.Entity("Sm_API.Api.Models.ClassRoom", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("GradeLevel") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("RoomNumber") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("TeacherId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("TeacherId"); + + b.ToTable("ClassRooms"); + }); + + modelBuilder.Entity("Sm_API.Api.Models.Enrollment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClassRoomId") + .HasColumnType("INTEGER"); + + b.Property("EnrollmentDate") + .HasColumnType("TEXT"); + + b.Property("StudentId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ClassRoomId"); + + b.HasIndex("StudentId", "ClassRoomId") + .IsUnique(); + + b.ToTable("Enrollments"); + }); + + modelBuilder.Entity("Sm_API.Api.Models.Student", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DateOfBirth") + .HasColumnType("TEXT"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("EnrollmentDate") + .HasColumnType("TEXT"); + + b.Property("FirstName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("LastName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.ToTable("Students"); + }); + + modelBuilder.Entity("Sm_API.Api.Models.Teacher", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("FirstName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("HireDate") + .HasColumnType("TEXT"); + + b.Property("LastName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.ToTable("Teachers"); + }); + + modelBuilder.Entity("Sm_API.Api.Models.ClassRoom", b => + { + b.HasOne("Sm_API.Api.Models.Teacher", "Teacher") + .WithMany("ClassRooms") + .HasForeignKey("TeacherId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Teacher"); + }); + + modelBuilder.Entity("Sm_API.Api.Models.Enrollment", b => + { + b.HasOne("Sm_API.Api.Models.ClassRoom", "ClassRoom") + .WithMany("Enrollments") + .HasForeignKey("ClassRoomId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Sm_API.Api.Models.Student", "Student") + .WithMany("Enrollments") + .HasForeignKey("StudentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ClassRoom"); + + b.Navigation("Student"); + }); + + modelBuilder.Entity("Sm_API.Api.Models.ClassRoom", b => + { + b.Navigation("Enrollments"); + }); + + modelBuilder.Entity("Sm_API.Api.Models.Student", b => + { + b.Navigation("Enrollments"); + }); + + modelBuilder.Entity("Sm_API.Api.Models.Teacher", b => + { + b.Navigation("ClassRooms"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Sm_API.Api/Data/Migrations/20260712061224_InitialCreate.cs b/src/Sm_API.Api/Data/Migrations/20260712061224_InitialCreate.cs new file mode 100644 index 0000000..ae75334 --- /dev/null +++ b/src/Sm_API.Api/Data/Migrations/20260712061224_InitialCreate.cs @@ -0,0 +1,142 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Sm_API.Api.Data.Migrations +{ + /// + public partial class InitialCreate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Students", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + FirstName = table.Column(type: "TEXT", maxLength: 100, nullable: false), + LastName = table.Column(type: "TEXT", maxLength: 100, nullable: false), + Email = table.Column(type: "TEXT", maxLength: 200, nullable: false), + DateOfBirth = table.Column(type: "TEXT", nullable: false), + EnrollmentDate = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Students", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Teachers", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + FirstName = table.Column(type: "TEXT", maxLength: 100, nullable: false), + LastName = table.Column(type: "TEXT", maxLength: 100, nullable: false), + Email = table.Column(type: "TEXT", maxLength: 200, nullable: false), + Subject = table.Column(type: "TEXT", maxLength: 100, nullable: false), + HireDate = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Teachers", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "ClassRooms", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Name = table.Column(type: "TEXT", maxLength: 100, nullable: false), + GradeLevel = table.Column(type: "INTEGER", nullable: false), + RoomNumber = table.Column(type: "TEXT", maxLength: 20, nullable: false), + TeacherId = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ClassRooms", x => x.Id); + table.ForeignKey( + name: "FK_ClassRooms_Teachers_TeacherId", + column: x => x.TeacherId, + principalTable: "Teachers", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "Enrollments", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + StudentId = table.Column(type: "INTEGER", nullable: false), + ClassRoomId = table.Column(type: "INTEGER", nullable: false), + EnrollmentDate = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Enrollments", x => x.Id); + table.ForeignKey( + name: "FK_Enrollments_ClassRooms_ClassRoomId", + column: x => x.ClassRoomId, + principalTable: "ClassRooms", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_Enrollments_Students_StudentId", + column: x => x.StudentId, + principalTable: "Students", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_ClassRooms_TeacherId", + table: "ClassRooms", + column: "TeacherId"); + + migrationBuilder.CreateIndex( + name: "IX_Enrollments_ClassRoomId", + table: "Enrollments", + column: "ClassRoomId"); + + migrationBuilder.CreateIndex( + name: "IX_Enrollments_StudentId_ClassRoomId", + table: "Enrollments", + columns: new[] { "StudentId", "ClassRoomId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Students_Email", + table: "Students", + column: "Email", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Teachers_Email", + table: "Teachers", + column: "Email", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Enrollments"); + + migrationBuilder.DropTable( + name: "ClassRooms"); + + migrationBuilder.DropTable( + name: "Students"); + + migrationBuilder.DropTable( + name: "Teachers"); + } + } +} diff --git a/src/Sm_API.Api/Data/Migrations/SmApiDbContextModelSnapshot.cs b/src/Sm_API.Api/Data/Migrations/SmApiDbContextModelSnapshot.cs new file mode 100644 index 0000000..3928b3b --- /dev/null +++ b/src/Sm_API.Api/Data/Migrations/SmApiDbContextModelSnapshot.cs @@ -0,0 +1,193 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Sm_API.Api.Data; + +#nullable disable + +namespace Sm_API.Api.Data.Migrations +{ + [DbContext(typeof(SmApiDbContext))] + partial class SmApiDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.9"); + + modelBuilder.Entity("Sm_API.Api.Models.ClassRoom", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("GradeLevel") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("RoomNumber") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("TeacherId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("TeacherId"); + + b.ToTable("ClassRooms"); + }); + + modelBuilder.Entity("Sm_API.Api.Models.Enrollment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClassRoomId") + .HasColumnType("INTEGER"); + + b.Property("EnrollmentDate") + .HasColumnType("TEXT"); + + b.Property("StudentId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ClassRoomId"); + + b.HasIndex("StudentId", "ClassRoomId") + .IsUnique(); + + b.ToTable("Enrollments"); + }); + + modelBuilder.Entity("Sm_API.Api.Models.Student", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DateOfBirth") + .HasColumnType("TEXT"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("EnrollmentDate") + .HasColumnType("TEXT"); + + b.Property("FirstName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("LastName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.ToTable("Students"); + }); + + modelBuilder.Entity("Sm_API.Api.Models.Teacher", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("FirstName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("HireDate") + .HasColumnType("TEXT"); + + b.Property("LastName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.ToTable("Teachers"); + }); + + modelBuilder.Entity("Sm_API.Api.Models.ClassRoom", b => + { + b.HasOne("Sm_API.Api.Models.Teacher", "Teacher") + .WithMany("ClassRooms") + .HasForeignKey("TeacherId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Teacher"); + }); + + modelBuilder.Entity("Sm_API.Api.Models.Enrollment", b => + { + b.HasOne("Sm_API.Api.Models.ClassRoom", "ClassRoom") + .WithMany("Enrollments") + .HasForeignKey("ClassRoomId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Sm_API.Api.Models.Student", "Student") + .WithMany("Enrollments") + .HasForeignKey("StudentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ClassRoom"); + + b.Navigation("Student"); + }); + + modelBuilder.Entity("Sm_API.Api.Models.ClassRoom", b => + { + b.Navigation("Enrollments"); + }); + + modelBuilder.Entity("Sm_API.Api.Models.Student", b => + { + b.Navigation("Enrollments"); + }); + + modelBuilder.Entity("Sm_API.Api.Models.Teacher", b => + { + b.Navigation("ClassRooms"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Sm_API.Api/Data/SmApiDbContext.cs b/src/Sm_API.Api/Data/SmApiDbContext.cs new file mode 100644 index 0000000..779f5ac --- /dev/null +++ b/src/Sm_API.Api/Data/SmApiDbContext.cs @@ -0,0 +1,49 @@ +using Microsoft.EntityFrameworkCore; +using Sm_API.Api.Models; + +namespace Sm_API.Api.Data; + +public class SmApiDbContext : DbContext +{ + public SmApiDbContext(DbContextOptions options) : base(options) + { + } + + public DbSet Students => Set(); + public DbSet Teachers => Set(); + public DbSet ClassRooms => Set(); + public DbSet Enrollments => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity() + .HasIndex(s => s.Email) + .IsUnique(); + + modelBuilder.Entity() + .HasIndex(t => t.Email) + .IsUnique(); + + modelBuilder.Entity() + .HasOne(c => c.Teacher) + .WithMany(t => t.ClassRooms) + .HasForeignKey(c => c.TeacherId) + .OnDelete(DeleteBehavior.Restrict); + + modelBuilder.Entity() + .HasOne(e => e.Student) + .WithMany(s => s.Enrollments) + .HasForeignKey(e => e.StudentId) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasOne(e => e.ClassRoom) + .WithMany(c => c.Enrollments) + .HasForeignKey(e => e.ClassRoomId) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasIndex(e => new { e.StudentId, e.ClassRoomId }) + .IsUnique(); + } +} diff --git a/src/Sm_API.Api/Dtos/ClassRoomDtos.cs b/src/Sm_API.Api/Dtos/ClassRoomDtos.cs new file mode 100644 index 0000000..1ad3406 --- /dev/null +++ b/src/Sm_API.Api/Dtos/ClassRoomDtos.cs @@ -0,0 +1,20 @@ +using System.ComponentModel.DataAnnotations; + +namespace Sm_API.Api.Dtos; + +public record ClassRoomReadDto(int Id, string Name, int GradeLevel, string RoomNumber, int TeacherId); + +public class ClassRoomWriteDto +{ + [Required, MaxLength(100)] + public string Name { get; set; } = string.Empty; + + [Range(1, 12)] + public int GradeLevel { get; set; } + + [Required, MaxLength(20)] + public string RoomNumber { get; set; } = string.Empty; + + [Required] + public int TeacherId { get; set; } +} diff --git a/src/Sm_API.Api/Dtos/EnrollmentDtos.cs b/src/Sm_API.Api/Dtos/EnrollmentDtos.cs new file mode 100644 index 0000000..daa0eb1 --- /dev/null +++ b/src/Sm_API.Api/Dtos/EnrollmentDtos.cs @@ -0,0 +1,16 @@ +using System.ComponentModel.DataAnnotations; + +namespace Sm_API.Api.Dtos; + +public record EnrollmentReadDto(int Id, int StudentId, int ClassRoomId, DateOnly EnrollmentDate); + +public class EnrollmentWriteDto +{ + [Required] + public int StudentId { get; set; } + + [Required] + public int ClassRoomId { get; set; } + + public DateOnly EnrollmentDate { get; set; } +} diff --git a/src/Sm_API.Api/Dtos/StudentDtos.cs b/src/Sm_API.Api/Dtos/StudentDtos.cs new file mode 100644 index 0000000..114aac8 --- /dev/null +++ b/src/Sm_API.Api/Dtos/StudentDtos.cs @@ -0,0 +1,21 @@ +using System.ComponentModel.DataAnnotations; + +namespace Sm_API.Api.Dtos; + +public record StudentReadDto(int Id, string FirstName, string LastName, string Email, DateOnly DateOfBirth, DateOnly EnrollmentDate); + +public class StudentWriteDto +{ + [Required, MaxLength(100)] + public string FirstName { get; set; } = string.Empty; + + [Required, MaxLength(100)] + public string LastName { get; set; } = string.Empty; + + [Required, EmailAddress, MaxLength(200)] + public string Email { get; set; } = string.Empty; + + public DateOnly DateOfBirth { get; set; } + + public DateOnly EnrollmentDate { get; set; } +} diff --git a/src/Sm_API.Api/Dtos/TeacherDtos.cs b/src/Sm_API.Api/Dtos/TeacherDtos.cs new file mode 100644 index 0000000..c0f577e --- /dev/null +++ b/src/Sm_API.Api/Dtos/TeacherDtos.cs @@ -0,0 +1,22 @@ +using System.ComponentModel.DataAnnotations; + +namespace Sm_API.Api.Dtos; + +public record TeacherReadDto(int Id, string FirstName, string LastName, string Email, string Subject, DateOnly HireDate); + +public class TeacherWriteDto +{ + [Required, MaxLength(100)] + public string FirstName { get; set; } = string.Empty; + + [Required, MaxLength(100)] + public string LastName { get; set; } = string.Empty; + + [Required, EmailAddress, MaxLength(200)] + public string Email { get; set; } = string.Empty; + + [Required, MaxLength(100)] + public string Subject { get; set; } = string.Empty; + + public DateOnly HireDate { get; set; } +} diff --git a/src/Sm_API.Api/Models/ClassRoom.cs b/src/Sm_API.Api/Models/ClassRoom.cs new file mode 100644 index 0000000..5579485 --- /dev/null +++ b/src/Sm_API.Api/Models/ClassRoom.cs @@ -0,0 +1,22 @@ +using System.ComponentModel.DataAnnotations; + +namespace Sm_API.Api.Models; + +public class ClassRoom +{ + public int Id { get; set; } + + [Required, MaxLength(100)] + public string Name { get; set; } = string.Empty; + + [Range(1, 12)] + public int GradeLevel { get; set; } + + [Required, MaxLength(20)] + public string RoomNumber { get; set; } = string.Empty; + + public int TeacherId { get; set; } + public Teacher? Teacher { get; set; } + + public ICollection Enrollments { get; set; } = new List(); +} diff --git a/src/Sm_API.Api/Models/Enrollment.cs b/src/Sm_API.Api/Models/Enrollment.cs new file mode 100644 index 0000000..f7dd6df --- /dev/null +++ b/src/Sm_API.Api/Models/Enrollment.cs @@ -0,0 +1,14 @@ +namespace Sm_API.Api.Models; + +public class Enrollment +{ + public int Id { get; set; } + + public int StudentId { get; set; } + public Student? Student { get; set; } + + public int ClassRoomId { get; set; } + public ClassRoom? ClassRoom { get; set; } + + public DateOnly EnrollmentDate { get; set; } +} diff --git a/src/Sm_API.Api/Models/Student.cs b/src/Sm_API.Api/Models/Student.cs new file mode 100644 index 0000000..dab2e82 --- /dev/null +++ b/src/Sm_API.Api/Models/Student.cs @@ -0,0 +1,23 @@ +using System.ComponentModel.DataAnnotations; + +namespace Sm_API.Api.Models; + +public class Student +{ + public int Id { get; set; } + + [Required, MaxLength(100)] + public string FirstName { get; set; } = string.Empty; + + [Required, MaxLength(100)] + public string LastName { get; set; } = string.Empty; + + [Required, EmailAddress, MaxLength(200)] + public string Email { get; set; } = string.Empty; + + public DateOnly DateOfBirth { get; set; } + + public DateOnly EnrollmentDate { get; set; } + + public ICollection Enrollments { get; set; } = new List(); +} diff --git a/src/Sm_API.Api/Models/Teacher.cs b/src/Sm_API.Api/Models/Teacher.cs new file mode 100644 index 0000000..f7789a3 --- /dev/null +++ b/src/Sm_API.Api/Models/Teacher.cs @@ -0,0 +1,24 @@ +using System.ComponentModel.DataAnnotations; + +namespace Sm_API.Api.Models; + +public class Teacher +{ + public int Id { get; set; } + + [Required, MaxLength(100)] + public string FirstName { get; set; } = string.Empty; + + [Required, MaxLength(100)] + public string LastName { get; set; } = string.Empty; + + [Required, EmailAddress, MaxLength(200)] + public string Email { get; set; } = string.Empty; + + [Required, MaxLength(100)] + public string Subject { get; set; } = string.Empty; + + public DateOnly HireDate { get; set; } + + public ICollection ClassRooms { get; set; } = new List(); +} diff --git a/src/Sm_API.Api/Program.cs b/src/Sm_API.Api/Program.cs new file mode 100644 index 0000000..df027d7 --- /dev/null +++ b/src/Sm_API.Api/Program.cs @@ -0,0 +1,59 @@ +using Microsoft.EntityFrameworkCore; +using Sm_API.Api.Data; + +var builder = WebApplication.CreateBuilder(args); + +// Add services to the container. + +builder.Services.AddControllers(); +// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi +builder.Services.AddOpenApi(); + +builder.Services.AddDbContext(options => + options.UseSqlite(builder.Configuration.GetConnectionString("DefaultConnection") + ?? "Data Source=smapi.db")); + +const string FrontendCorsPolicy = "FrontendCorsPolicy"; +var allowedOrigins = builder.Configuration.GetSection("Cors:AllowedOrigins").Get() + ?? new[] { "http://localhost:5173", "http://localhost:3000" }; + +builder.Services.AddCors(options => +{ + options.AddPolicy(FrontendCorsPolicy, policy => + policy.WithOrigins(allowedOrigins) + .AllowAnyHeader() + .AllowAnyMethod()); +}); + +var app = builder.Build(); + +// Configure the HTTP request pipeline. +if (app.Environment.IsDevelopment()) +{ + app.MapOpenApi(); +} + +app.UseCors(FrontendCorsPolicy); + +using (var scope = app.Services.CreateScope()) +{ + var db = scope.ServiceProvider.GetRequiredService(); + if (db.Database.IsRelational()) + { + db.Database.Migrate(); + } + else + { + db.Database.EnsureCreated(); + } +} + +app.UseHttpsRedirection(); + +app.UseAuthorization(); + +app.MapControllers(); + +app.Run(); + +public partial class Program { } diff --git a/src/Sm_API.Api/Properties/launchSettings.json b/src/Sm_API.Api/Properties/launchSettings.json new file mode 100644 index 0000000..6ae5db1 --- /dev/null +++ b/src/Sm_API.Api/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5140", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "https://localhost:7127;http://localhost:5140", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/Sm_API.Api/Sm_API.Api.csproj b/src/Sm_API.Api/Sm_API.Api.csproj new file mode 100644 index 0000000..fc97836 --- /dev/null +++ b/src/Sm_API.Api/Sm_API.Api.csproj @@ -0,0 +1,20 @@ + + + + net10.0 + enable + enable + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + diff --git a/src/Sm_API.Api/Sm_API.Api.http b/src/Sm_API.Api/Sm_API.Api.http new file mode 100644 index 0000000..2d958a3 --- /dev/null +++ b/src/Sm_API.Api/Sm_API.Api.http @@ -0,0 +1,6 @@ +@Sm_API.Api_HostAddress = http://localhost:5140 + +GET {{Sm_API.Api_HostAddress}}/weatherforecast/ +Accept: application/json + +### diff --git a/src/Sm_API.Api/appsettings.Development.json b/src/Sm_API.Api/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/src/Sm_API.Api/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/src/Sm_API.Api/appsettings.json b/src/Sm_API.Api/appsettings.json new file mode 100644 index 0000000..3477978 --- /dev/null +++ b/src/Sm_API.Api/appsettings.json @@ -0,0 +1,15 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "ConnectionStrings": { + "DefaultConnection": "Data Source=smapi.db" + }, + "Cors": { + "AllowedOrigins": [ "http://localhost:5173", "http://localhost:3000" ] + } +} diff --git a/tests/Sm_API.Tests/SchoolWorkflowTests.cs b/tests/Sm_API.Tests/SchoolWorkflowTests.cs new file mode 100644 index 0000000..6c9e3c7 --- /dev/null +++ b/tests/Sm_API.Tests/SchoolWorkflowTests.cs @@ -0,0 +1,108 @@ +using System.Net; +using System.Net.Http.Json; +using Sm_API.Api.Dtos; +using Xunit; + +namespace Sm_API.Tests; + +public class SchoolWorkflowTests : IClassFixture +{ + private readonly HttpClient _client; + + public SchoolWorkflowTests(SmApiWebApplicationFactory factory) + { + _client = factory.CreateClient(); + } + + [Fact] + public async Task FullWorkflow_TeacherClassRoomStudentEnrollment_Succeeds() + { + var teacherDto = new TeacherWriteDto + { + FirstName = "Grace", + LastName = "Hopper", + Email = $"grace.{Guid.NewGuid()}@school.test", + Subject = "Computer Science", + HireDate = new DateOnly(2020, 1, 15) + }; + var teacherResponse = await _client.PostAsJsonAsync("/api/teachers", teacherDto); + Assert.Equal(HttpStatusCode.Created, teacherResponse.StatusCode); + var teacher = await teacherResponse.Content.ReadFromJsonAsync(); + + var classRoomDto = new ClassRoomWriteDto + { + Name = "Intro to Programming", + GradeLevel = 9, + RoomNumber = "B12", + TeacherId = teacher!.Id + }; + var classRoomResponse = await _client.PostAsJsonAsync("/api/classrooms", classRoomDto); + Assert.Equal(HttpStatusCode.Created, classRoomResponse.StatusCode); + var classRoom = await classRoomResponse.Content.ReadFromJsonAsync(); + + var studentDto = new StudentWriteDto + { + FirstName = "Alan", + LastName = "Turing", + Email = $"alan.{Guid.NewGuid()}@school.test", + DateOfBirth = new DateOnly(2011, 6, 23), + EnrollmentDate = new DateOnly(2024, 9, 1) + }; + var studentResponse = await _client.PostAsJsonAsync("/api/students", studentDto); + var student = await studentResponse.Content.ReadFromJsonAsync(); + + var enrollmentDto = new EnrollmentWriteDto + { + StudentId = student!.Id, + ClassRoomId = classRoom!.Id, + EnrollmentDate = new DateOnly(2024, 9, 1) + }; + var enrollmentResponse = await _client.PostAsJsonAsync("/api/enrollments", enrollmentDto); + Assert.Equal(HttpStatusCode.Created, enrollmentResponse.StatusCode); + + var duplicateEnrollment = await _client.PostAsJsonAsync("/api/enrollments", enrollmentDto); + Assert.Equal(HttpStatusCode.Conflict, duplicateEnrollment.StatusCode); + } + + [Fact] + public async Task CreateClassRoom_UnknownTeacher_ReturnsBadRequest() + { + var classRoomDto = new ClassRoomWriteDto + { + Name = "Ghost Class", + GradeLevel = 5, + RoomNumber = "X1", + TeacherId = 999999 + }; + + var response = await _client.PostAsJsonAsync("/api/classrooms", classRoomDto); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task DeleteTeacher_AssignedToClass_ReturnsConflict() + { + var teacherDto = new TeacherWriteDto + { + FirstName = "Marie", + LastName = "Curie", + Email = $"marie.{Guid.NewGuid()}@school.test", + Subject = "Chemistry", + HireDate = new DateOnly(2019, 8, 20) + }; + var teacherResponse = await _client.PostAsJsonAsync("/api/teachers", teacherDto); + var teacher = await teacherResponse.Content.ReadFromJsonAsync(); + + var classRoomDto = new ClassRoomWriteDto + { + Name = "Chemistry 101", + GradeLevel = 10, + RoomNumber = "C3", + TeacherId = teacher!.Id + }; + await _client.PostAsJsonAsync("/api/classrooms", classRoomDto); + + var deleteResponse = await _client.DeleteAsync($"/api/teachers/{teacher.Id}"); + Assert.Equal(HttpStatusCode.Conflict, deleteResponse.StatusCode); + } +} diff --git a/tests/Sm_API.Tests/SmApiWebApplicationFactory.cs b/tests/Sm_API.Tests/SmApiWebApplicationFactory.cs new file mode 100644 index 0000000..51ee4a7 --- /dev/null +++ b/tests/Sm_API.Tests/SmApiWebApplicationFactory.cs @@ -0,0 +1,44 @@ +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Sm_API.Api.Data; + +namespace Sm_API.Tests; + +// Uses a real SQLite in-memory connection (not the EF InMemory provider) so +// constraints, unique indexes, and cascade behavior match production exactly. +public class SmApiWebApplicationFactory : WebApplicationFactory +{ + private readonly SqliteConnection _connection = new("DataSource=:memory:"); + + public SmApiWebApplicationFactory() + { + _connection.Open(); + } + + protected override void ConfigureWebHost(Microsoft.AspNetCore.Hosting.IWebHostBuilder builder) + { + builder.ConfigureServices(services => + { + services.RemoveAll>(); + services.RemoveAll(); + services.RemoveAll(); + services.RemoveAll>(); + + services.AddDbContext(options => + options.UseSqlite(_connection)); + }); + } + + protected override void Dispose(bool disposing) + { + base.Dispose(disposing); + if (disposing) + { + _connection.Dispose(); + } + } +} diff --git a/tests/Sm_API.Tests/Sm_API.Tests.csproj b/tests/Sm_API.Tests/Sm_API.Tests.csproj new file mode 100644 index 0000000..1cc3441 --- /dev/null +++ b/tests/Sm_API.Tests/Sm_API.Tests.csproj @@ -0,0 +1,27 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/Sm_API.Tests/StudentsControllerTests.cs b/tests/Sm_API.Tests/StudentsControllerTests.cs new file mode 100644 index 0000000..d6df10b --- /dev/null +++ b/tests/Sm_API.Tests/StudentsControllerTests.cs @@ -0,0 +1,102 @@ +using System.Net; +using System.Net.Http.Json; +using Sm_API.Api.Dtos; +using Xunit; + +namespace Sm_API.Tests; + +public class StudentsControllerTests : IClassFixture +{ + private readonly HttpClient _client; + + public StudentsControllerTests(SmApiWebApplicationFactory factory) + { + _client = factory.CreateClient(); + } + + private static StudentWriteDto NewStudent(string email) => new() + { + FirstName = "Ada", + LastName = "Lovelace", + Email = email, + DateOfBirth = new DateOnly(2012, 5, 1), + EnrollmentDate = new DateOnly(2024, 9, 1) + }; + + [Fact] + public async Task Create_Then_Get_ReturnsStudent() + { + var dto = NewStudent($"ada.{Guid.NewGuid()}@school.test"); + + var createResponse = await _client.PostAsJsonAsync("/api/students", dto); + Assert.Equal(HttpStatusCode.Created, createResponse.StatusCode); + + var created = await createResponse.Content.ReadFromJsonAsync(); + Assert.NotNull(created); + + var getResponse = await _client.GetAsync($"/api/students/{created!.Id}"); + Assert.Equal(HttpStatusCode.OK, getResponse.StatusCode); + + var fetched = await getResponse.Content.ReadFromJsonAsync(); + Assert.Equal(dto.Email, fetched!.Email); + } + + [Fact] + public async Task GetById_UnknownId_ReturnsNotFound() + { + var response = await _client.GetAsync("/api/students/999999"); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task Create_DuplicateEmail_ReturnsConflict() + { + var email = $"dup.{Guid.NewGuid()}@school.test"; + var dto = NewStudent(email); + + var first = await _client.PostAsJsonAsync("/api/students", dto); + Assert.Equal(HttpStatusCode.Created, first.StatusCode); + + var second = await _client.PostAsJsonAsync("/api/students", dto); + Assert.Equal(HttpStatusCode.Conflict, second.StatusCode); + } + + [Fact] + public async Task Create_InvalidEmail_ReturnsBadRequest() + { + var dto = NewStudent("not-an-email"); + + var response = await _client.PostAsJsonAsync("/api/students", dto); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task Update_ExistingStudent_PersistsChanges() + { + var dto = NewStudent($"update.{Guid.NewGuid()}@school.test"); + var createResponse = await _client.PostAsJsonAsync("/api/students", dto); + var created = await createResponse.Content.ReadFromJsonAsync(); + + dto.LastName = "Byron"; + var updateResponse = await _client.PutAsJsonAsync($"/api/students/{created!.Id}", dto); + Assert.Equal(HttpStatusCode.NoContent, updateResponse.StatusCode); + + var getResponse = await _client.GetAsync($"/api/students/{created.Id}"); + var fetched = await getResponse.Content.ReadFromJsonAsync(); + Assert.Equal("Byron", fetched!.LastName); + } + + [Fact] + public async Task Delete_ExistingStudent_RemovesIt() + { + var dto = NewStudent($"delete.{Guid.NewGuid()}@school.test"); + var createResponse = await _client.PostAsJsonAsync("/api/students", dto); + var created = await createResponse.Content.ReadFromJsonAsync(); + + var deleteResponse = await _client.DeleteAsync($"/api/students/{created!.Id}"); + Assert.Equal(HttpStatusCode.NoContent, deleteResponse.StatusCode); + + var getResponse = await _client.GetAsync($"/api/students/{created.Id}"); + Assert.Equal(HttpStatusCode.NotFound, getResponse.StatusCode); + } +}