Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -58,3 +58,5 @@ Seed__DemoAdminEmail=admin@widgetworks.demo
Seed__DemoAdminPassword=DemoAdmin!Change01
Seed__DemoCustomerEmail=demo@widgetworks.demo
Seed__DemoCustomerPassword=DemoUser!Change01
Seed__DemoManagerEmail=manager@widgetworks.demo
Seed__DemoManagerPassword=DemoManager!Change01
112 changes: 112 additions & 0 deletions .github/workflows/deploy-api.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
name: Deploy API

# Fires ONLY for changes that can affect the compiled API. `paths` is an allowlist, so a
# docs-only or web-only commit simply does not match and no deployment runs — the SPA has its
# own workflow, and neither is triggered by markdown.
on:
push:
branches: [main]
paths:
- 'src/**'
- 'tests/**'
- 'Directory.Build.props'
- 'global.json'
- 'WidgetWorks.slnx'
- 'Dockerfile.api'
- 'docker-compose.yml'
- 'scripts/smoke-test.ps1'
- '.github/workflows/deploy-api.yml'
- '.github/workflows/test-suite.yml'
workflow_dispatch:

permissions:
contents: read

concurrency:
group: deploy-api
cancel-in-progress: false

env:
APP_NAME: widgetworks-api-41d09d
RESOURCE_GROUP: rg-widgetworks

jobs:
# The gate. Backend units, frontend units and the end-to-end smoke test must all pass;
# `needs: tests` below means a single failure stops the deployment entirely.
tests:
name: Tests
uses: ./.github/workflows/test-suite.yml

deploy:
name: Publish and deploy the API
needs: tests
runs-on: ubuntu-latest
environment: production
# No stored Azure credential: id-token lets the job exchange a short-lived GitHub OIDC
# token for an Azure one. contents:read is all the checkout needs.
permissions:
contents: read
id-token: write
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- name: Setup .NET
uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
with:
dotnet-version: '10.0.x'

# Publish to a directory OUTSIDE the workspace, so the artifact cannot pick up the
# repository. Deploying the repo would serve .cs sources and git history from wwwroot.
- name: Publish (Release)
run: dotnet publish src/WidgetWorks.WebApi/WidgetWorks.WebApi.csproj -c Release -o "${{ runner.temp }}/publish" --nologo

- name: Audit the publish output
run: |
cd "${{ runner.temp }}/publish"
bad=$(find . \( -name '*.cs' -o -name '*.csproj' -o -name '*.sln*' -o -name '.env' \
-o -name 'docker-compose*.yml' \) -print)
for d in .git node_modules src web tests docs; do
[ -e "$d" ] && bad="$bad $d/"
done
if [ -n "$(printf '%s' "$bad" | tr -d '[:space:]')" ]; then
echo "::error::Publish output contains files that must never be deployed:"
printf '%s\n' "$bad"
exit 1
fi
test -f WidgetWorks.WebApi.dll || { echo "::error::app dll missing"; exit 1; }
test -f appsettings.json || { echo "::error::appsettings.json missing"; exit 1; }
echo "Clean: $(find . -type f | wc -l) build-output files"

- name: Azure login (OIDC — no stored credential)
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2.3.0
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}

- name: Deploy
uses: azure/webapps-deploy@2fdd5c3ebb4e540834e86ecc1f6fdcd5539023ee # v3.0.2
with:
app-name: ${{ env.APP_NAME }}
package: ${{ runner.temp }}/publish

# The API degrades to a 503 rather than crash-looping when the database is unreachable,
# so a bad deploy shows up here instead of silently eating the F1 tier's CPU quota.
- name: Health check (stops the app if unhealthy)
run: |
for i in $(seq 1 20); do
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 20 \
"https://${{ env.APP_NAME }}.azurewebsites.net/health" || true)
if [ "$code" = "200" ]; then echo "healthy"; exit 0; fi
if [ "$code" = "503" ]; then
echo "::error::App is up but unhealthy (database unreachable). Stopping it to protect CPU quota."
az webapp stop --name "${{ env.APP_NAME }}" --resource-group "${{ env.RESOURCE_GROUP }}"
exit 1
fi
echo " $code (attempt $i/20)"
sleep 6
done
echo "::error::No healthy response; stopping the app to protect CPU quota."
az webapp stop --name "${{ env.APP_NAME }}" --resource-group "${{ env.RESOURCE_GROUP }}"
exit 1
74 changes: 74 additions & 0 deletions .github/workflows/deploy-web.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
name: Deploy web

# Fires ONLY for changes under web/. `paths` is an allowlist, so a docs-only or API-only commit
# does not match and no deployment runs. The API has its own workflow; neither is triggered by
# markdown.
on:
push:
branches: [main]
paths:
- 'web/**'
- '.github/workflows/deploy-web.yml'
- '.github/workflows/test-suite.yml'
workflow_dispatch:

permissions:
contents: read

concurrency:
group: deploy-web
cancel-in-progress: true

jobs:
# The same gate the API deploy uses. The SPA is useless against a broken API, so the
# end-to-end smoke test guards this deployment too, not just the frontend unit tests.
tests:
name: Tests
uses: ./.github/workflows/test-suite.yml

deploy:
name: Build and deploy the SPA
needs: tests
runs-on: ubuntu-latest
environment: production
defaults:
run:
working-directory: web
env:
# Public build-time config from Actions Variables. Vite inlines both into the bundle, so
# neither may ever be a secret: VITE_* values ship to every browser.
VITE_API_BASE_URL: ${{ vars.VITE_API_BASE_URL }}
VITE_GOOGLE_CLIENT_ID: ${{ vars.VITE_GOOGLE_CLIENT_ID }}
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- name: Install
run: npm ci --no-audit --no-fund

- name: Build
run: npm run build

# staticwebapp.config.json ships a placeholder because the API hostname is not known at
# authoring time. Without this substitution the CSP blocks every call the SPA makes to its
# own API, and Google sign-in fails silently.
- name: Point the CSP at the API
run: |
test -n "$VITE_API_BASE_URL" || { echo "::error::VITE_API_BASE_URL variable is not set"; exit 1; }
cfg=dist/staticwebapp.config.json
test -f "$cfg" || { echo "::error::$cfg missing — it must live in web/public/"; exit 1; }
sed -i "s|https://REPLACE_API_ORIGIN|${VITE_API_BASE_URL}|" "$cfg"
if grep -q REPLACE_API_ORIGIN "$cfg"; then
echo "::error::CSP placeholder not substituted"; exit 1
fi
grep -o "connect-src[^;]*" "$cfg"

# Uploads dist/ only — the built bundle, never the source tree.
- name: Deploy to Static Web Apps
uses: Azure/static-web-apps-deploy@1a947af9992250f3bc2e68ad0754c0b0c11566c9 # v1
with:
azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN }}
action: upload
app_location: web/dist
skip_app_build: true
skip_api_build: true
76 changes: 76 additions & 0 deletions .github/workflows/test-suite.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
name: Test suite

# Reusable gate: every deployment calls this and will not proceed unless all three jobs pass.
# Kept in one file so the API and web deploys cannot drift apart on what "tests passed" means.
on:
workflow_call:

permissions:
contents: read

jobs:
backend:
name: Backend unit tests (xUnit)
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Setup .NET
uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
with:
dotnet-version: '10.0.x'
- name: Test
run: dotnet test WidgetWorks.slnx --configuration Release --nologo

frontend:
name: Frontend unit tests (Vitest)
runs-on: ubuntu-latest
defaults:
run:
working-directory: web
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install
run: npm ci --no-audit --no-fund
- name: Test
run: npm test
- name: Build (type-check + bundle)
run: npm run build

smoke:
name: API smoke test (end-to-end)
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- name: Prepare env (placeholder values are valid for CI)
run: cp .env.example .env

- name: Start database + API
run: docker compose up -d --build db api

- name: Wait for API health
run: |
for i in $(seq 1 80); do
if curl -fsS http://localhost:8080/health >/dev/null 2>&1; then
echo "API healthy"; exit 0
fi
sleep 3
done
echo "::error::API did not become healthy in time"
docker compose logs api
exit 1

- name: Run smoke test (PowerShell)
shell: pwsh
run: ./scripts/smoke-test.ps1 -BaseUrl http://localhost:8080

- name: Dump container logs on failure
if: failure()
run: docker compose logs

- name: Tear down
if: always()
run: docker compose down -v
21 changes: 14 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@ docker compose up --build

| What | URL |
|---|---|
| Store (SPA) | http://localhost:3000 |
| **Start here** — demo guide / landing page | http://localhost:3000 |
| Store (SPA) | http://localhost:3000/store |
| **Mailpit** — every email the app sends | http://localhost:8025 |
| API + Scalar (interactive API UI) | http://localhost:8080/scalar/v1 |
| Health | http://localhost:8080/health |

Expand All @@ -35,10 +37,11 @@ with fast iteration (and the exact port/user-secrets details), see

### Demo accounts (local demo only)

| Role | Email | Password |
|------|-------|----------|
| Administrator (immutable) | `admin@widgetworks.demo` | `DemoAdmin!Change01` |
| Customer | `demo@widgetworks.demo` | `DemoUser!Change01` |
| Role | Email | Password | What it can do |
|------|-------|----------|----------------|
| Administrator (immutable) | `admin@widgetworks.demo` | `DemoAdmin!Change01` | Everything — plus retiring a widget and managing users |
| Manager | `manager@widgetworks.demo` | `DemoManager!Change01` | Catalog + order fulfilment; **not** delete or user management |
| Customer | `demo@widgetworks.demo` | `DemoUser!Change01` | Shop, check out, see their own orders |

Passwords are set from `.env` / user-secrets at seed time — the one sanctioned, documented
"credential" in the repo. The admin has no 2FA by default, so it logs straight in.
Expand Down Expand Up @@ -134,11 +137,15 @@ src/
WidgetWorks.WebApi Minimal API endpoints, DI, auth wiring
tests/WidgetWorks.UnitTests xUnit tests with in-memory fakes + FakeTimeProvider
web/ React + TypeScript SPA (Vite) — see web/README.md
scripts/ smoke-test.ps1 and tooling
infra/ Provision.ps1 — idempotent Azure provisioning
scripts/ smoke-test.ps1, deploy helpers, tooling
.github/workflows/ CI, path-scoped deploys, the reusable test suite
docs/ handbook + architecture ADRs
Dockerfile.api, Dockerfile.web, docker-compose.yml
```

## License

See [`LICENSE`](LICENSE).
No `LICENSE` file is published yet, so the default applies: **all rights reserved** — the
source is public to read, not licensed for reuse. Adding a license (MIT is the usual choice
for a portfolio project) is a one-file change.
5 changes: 3 additions & 2 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,9 @@ artifacts are committed to this repository. This is enforced at three layers:

### The one sanctioned exception: demo seed accounts

The seeded **demo admin** (`admin@widgetworks.demo`) and **demo customer**
(`demo@widgetworks.demo`) use documented, throwaway credentials so reviewers can
The seeded **demo admin** (`admin@widgetworks.demo`), **demo manager**
(`manager@widgetworks.demo`) and **demo customer** (`demo@widgetworks.demo`) use
documented, throwaway credentials so reviewers can
log in. These are intentionally public, are the only "credentials" in the repo,
and are allowlisted in `.gitleaks.toml`. They grant access only to a local,
disposable demo database.
Expand Down
1 change: 1 addition & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ services:
Google__ClientId: ${Google__ClientId:-}
Seed__DemoAdminPassword: ${Seed__DemoAdminPassword:-DemoAdmin!Change01}
Seed__DemoCustomerPassword: ${Seed__DemoCustomerPassword:-DemoUser!Change01}
Seed__DemoManagerPassword: ${Seed__DemoManagerPassword:-DemoManager!Change01}
ports:
- "8080:8080"
depends_on:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -321,7 +321,7 @@ Recommended default: a **composite** of small strategies so the calculation is v
## 11. Migrations without EF

- **DbUp** runs ordered, embedded **`.sql`** scripts on startup, tracking applied scripts in a journal table. Plain SQL keeps us "close to production" and reviewable.
- Seed data ships as idempotent scripts including **two demo accounts** documented in the README: (1) an **immutable admin** (`admin@widgetworks.demo`) managing widgets/inventory/orders, and (2) a **demo customer** (`demo@widgetworks.demo`). Plus categories and sample **widgets**, each with a name, description, product image, price, weight, and opening quantity on hand (`quantity_reserved = 0`).
- Seed data ships as idempotent scripts including **three demo accounts** documented in the README: (1) an **immutable admin** (`admin@widgetworks.demo`) managing widgets/inventory/orders, (2) a **manager** (`manager@widgetworks.demo`) with catalog and order-fulfilment rights but no delete or user management, and (3) a **demo customer** (`demo@widgetworks.demo`). Plus categories and sample **widgets**, each with a name, description, product image, price, weight, and opening quantity on hand (`quantity_reserved = 0`).
- Alternative: **FluentMigrator** for C#‑authored migrations with up/down.

---
Expand Down
3 changes: 2 additions & 1 deletion docs/architecture/03-scope-decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,8 @@ and (later) cloud/tax-service credentials.
DB password, JWT signing key, and any future Avalara/TaxJar key) lives in
user-secrets / Actions secrets / OIDC — **never** the repo.
- The **only** allowlisted "credentials" are the documented, throwaway **demo
accounts** (`admin@widgetworks.demo`, `demo@widgetworks.demo`).
accounts** (`admin@widgetworks.demo`, `manager@widgetworks.demo`,
`demo@widgetworks.demo`).
- Enforcement: pre-commit (gitleaks + detect-private-key + forbidden-artifact guard)
→ CI gitleaks gate → recommend enabling GitHub native **secret scanning + push
protection**.
Expand Down
Loading
Loading