Compare commits
14 Commits
f74ba51d7a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 02ca56358d | |||
| 3fee1e9ed7 | |||
| 3be6a2b7ef | |||
| 03a47396c5 | |||
| a26cc96239 | |||
| 2a6ad23523 | |||
| 849383d6c8 | |||
| 63b892b10f | |||
| 886cbab36d | |||
| a385765030 | |||
| ab4918adfc | |||
| 17de45563d | |||
| e5a1979b1f | |||
| 92e53a6801 |
@@ -6,7 +6,6 @@
|
||||
name: Docker Push
|
||||
|
||||
on:
|
||||
# Manual trigger for testing or production
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
ref:
|
||||
@@ -14,6 +13,18 @@ on:
|
||||
required: false
|
||||
type: string
|
||||
default: ''
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths-ignore:
|
||||
- 'README.md'
|
||||
- 'AGENTS.md'
|
||||
- 'CHANGELOG.md'
|
||||
- 'AGENT_CHANGELOG.md'
|
||||
- 'documentation/**'
|
||||
- 'adr/**'
|
||||
- 'chart/**'
|
||||
- 'features/**'
|
||||
|
||||
# Environment variables
|
||||
env:
|
||||
|
||||
60
.gitea/workflows/vault.yaml
Normal file
60
.gitea/workflows/vault.yaml
Normal file
@@ -0,0 +1,60 @@
|
||||
---
|
||||
name: Hashicorp Vault
|
||||
|
||||
on:
|
||||
workflow_dispatch: {}
|
||||
push: &vaultPaths
|
||||
paths:
|
||||
- 'iac/*.tf'
|
||||
pull_request: *vaultPaths
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.ref }}-${{ github.workflow }}
|
||||
cancel-in-progress: true
|
||||
|
||||
.vault_step: &vault_step
|
||||
name: read vault secret
|
||||
uses: https://gitea.arcodange.lab/arcodange-org/vault-action.git@main
|
||||
id: vault-secrets
|
||||
with:
|
||||
url: https://vault.arcodange.lab
|
||||
caCertificate: ${{ secrets.HOMELAB_CA_CERT }}
|
||||
jwtGiteaOIDC: ${{ needs.gitea_vault_auth.outputs.gitea_vault_jwt }}
|
||||
role: gitea_cicd_dance-lessons-coach
|
||||
method: jwt
|
||||
path: gitea_jwt
|
||||
secrets: |
|
||||
kvv1/google/credentials credentials | GOOGLE_BACKEND_CREDENTIALS ;
|
||||
kvv1/gitea/tofu_module_reader ssh_private_key | TERRAFORM_SSH_KEY ;
|
||||
|
||||
jobs:
|
||||
gitea_vault_auth:
|
||||
name: Auth with gitea for vault
|
||||
runs-on: ubuntu-latest-ca
|
||||
outputs:
|
||||
gitea_vault_jwt: ${{steps.gitea_vault_jwt.outputs.id_token}}
|
||||
steps:
|
||||
- name: Auth with gitea for vault
|
||||
id: gitea_vault_jwt
|
||||
run: |
|
||||
echo -n "${{ secrets.vault_oauth__sh_b64 }}" | base64 -d | bash
|
||||
|
||||
tofu:
|
||||
name: Tofu - Vault
|
||||
needs:
|
||||
- gitea_vault_auth
|
||||
runs-on: ubuntu-latest-ca
|
||||
env:
|
||||
OPENTOFU_VERSION: 1.8.2
|
||||
TERRAFORM_VAULT_AUTH_JWT: ${{ needs.gitea_vault_auth.outputs.gitea_vault_jwt }}
|
||||
VAULT_CACERT: "${{ github.workspace }}/homelab.pem"
|
||||
steps:
|
||||
- *vault_step
|
||||
- uses: actions/checkout@v4
|
||||
- name: prepare vault self signed cert
|
||||
run: echo -n "${{ secrets.HOMELAB_CA_CERT }}" | base64 -d > $VAULT_CACERT
|
||||
- name: terraform apply
|
||||
uses: dflook/terraform-apply@v1
|
||||
with:
|
||||
path: iac
|
||||
auto_approve: true
|
||||
43
AGENTS.md
43
AGENTS.md
@@ -11,6 +11,9 @@ AI agent reference for developing, testing, and operating the dance-lessons-coac
|
||||
| Logging | Zerolog | v1.35.0 |
|
||||
| Configuration | Viper | v1.21.0 |
|
||||
| Telemetry | OpenTelemetry | v1.43.0 |
|
||||
| Database | PostgreSQL + GORM | (optional, for user persistence) |
|
||||
| Auth | JWT + bcrypt | with rotating secrets |
|
||||
| Email | SMTP (Mailpit dev) | for magic-link delivery |
|
||||
|
||||
## Project Structure
|
||||
|
||||
@@ -21,11 +24,14 @@ dance-lessons-coach/
|
||||
│ ├── greet/ # CLI application
|
||||
│ └── server/ # Web server entry point
|
||||
├── pkg/
|
||||
│ ├── auth/ # Auth context keys, OIDC client, helpers
|
||||
│ ├── config/ # Viper-based configuration
|
||||
│ ├── email/ # SMTP sender abstraction
|
||||
│ ├── greet/ # Core domain logic + API handlers
|
||||
│ ├── server/ # HTTP server, routing, graceful shutdown
|
||||
│ ├── telemetry/ # OpenTelemetry instrumentation
|
||||
│ ├── user/ # User domain (auth, JWT, repository)
|
||||
│ ├── user/api/ # Auth + admin HTTP handlers
|
||||
│ └── validation/ # Request validation
|
||||
├── scripts/ # Server lifecycle, build, test scripts
|
||||
├── config.yaml # Configuration file
|
||||
@@ -80,20 +86,35 @@ logging:
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/health` | Liveness — always `{"status":"healthy"}` |
|
||||
| GET | `/api/healthz` | Alternative liveness probe (k8s convention) |
|
||||
| GET | `/api/ready` | Readiness — 200 when ready, 503 during shutdown |
|
||||
| GET | `/api/info` | Composite info endpoint (cf. ADR-0026) |
|
||||
| GET | `/api/version` | Version info (`?format=plain\|full\|json`) |
|
||||
| GET | `/api/v1/greet/` | Default greeting |
|
||||
| GET | `/api/v1/greet/{name}` | Personalized greeting |
|
||||
| POST | `/api/v1/auth/login` | Username + password login (JWT) |
|
||||
| POST | `/api/v1/auth/register` | Account creation |
|
||||
| POST | `/api/v1/auth/validate` | JWT validation |
|
||||
| POST | `/api/v1/auth/password-reset/request` | Request a password reset |
|
||||
| POST | `/api/v1/auth/password-reset/complete` | Complete a password reset |
|
||||
| POST | `/api/v1/auth/magic-link/request` | Passwordless : request a magic link by email |
|
||||
| GET | `/api/v1/auth/magic-link/consume` | Passwordless : consume a magic-link token |
|
||||
| GET | `/api/v1/auth/oidc/{provider}/start` | OIDC : begin authorization (PKCE) |
|
||||
| GET | `/api/v1/auth/oidc/{provider}/callback` | OIDC : authorization code → JWT |
|
||||
| GET | `/api/v1/admin/jwt/secrets` | Admin : list JWT signing secrets |
|
||||
| POST | `/api/v1/admin/jwt/secrets` | Admin : add a JWT signing secret |
|
||||
| POST | `/api/v1/admin/jwt/secrets/rotate` | Admin : rotate the active JWT secret |
|
||||
| POST | `/api/v2/greet` | V2 greeting with validation (feature-flagged) |
|
||||
| POST | `/api/admin/cache/flush` | Admin : flush in-memory caches |
|
||||
| GET | `/swagger/` | Swagger UI |
|
||||
| GET | `/swagger/doc.json` | OpenAPI spec |
|
||||
| GET | `/swagger/doc.json` | OpenAPI spec (source of truth) |
|
||||
|
||||
```bash
|
||||
curl http://localhost:8080/api/health
|
||||
curl http://localhost:8080/api/ready
|
||||
curl http://localhost:8080/api/v1/greet/Alice
|
||||
curl -X POST http://localhost:8080/api/v2/greet \
|
||||
-H "Content-Type: application/json" -d '{"name":"Alice"}'
|
||||
|
||||
# See `/swagger/` for the full interactive list of endpoints.
|
||||
# The OpenAPI spec at `/swagger/doc.json` is the source of truth.
|
||||
```
|
||||
|
||||
## Testing
|
||||
@@ -151,19 +172,9 @@ docker run -d --name jaeger \
|
||||
|
||||
## Architecture Decision Records
|
||||
|
||||
| ADR | Decision |
|
||||
|-----|----------|
|
||||
| [0001](adr/0001-go-1.26.1-standard.md) | Go 1.26.1 |
|
||||
| [0002](adr/0002-chi-router.md) | Chi router |
|
||||
| [0003](adr/0003-zerolog-logging.md) | Zerolog |
|
||||
| [0004](adr/0004-interface-based-design.md) | Interface-based design |
|
||||
| [0005](adr/0005-graceful-shutdown.md) | Graceful shutdown |
|
||||
| [0006](adr/0006-configuration-management.md) | Viper configuration |
|
||||
| [0007](adr/0007-opentelemetry-integration.md) | OpenTelemetry |
|
||||
| [0008](adr/0008-bdd-testing.md) | BDD with Godog |
|
||||
| [0009](adr/0009-hybrid-testing-approach.md) | Hybrid testing strategy |
|
||||
The full index is at [adr/README.md](adr/README.md) — 30 ADRs covering Go version, Chi router, Zerolog, OpenTelemetry, BDD testing, JWT auth, PostgreSQL, OIDC migration, email infrastructure, etc.
|
||||
|
||||
Add a new ADR: copy an existing file, edit it, update `adr/README.md`.
|
||||
Add a new ADR : copy an existing file in `adr/`, edit it, update `adr/README.md`.
|
||||
|
||||
## Commit Conventions
|
||||
|
||||
|
||||
@@ -22,6 +22,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- 📝 documentation/2026-05-05-AUTONOMOUS-SESSION-RECAP.md captures the day's 24 Mistral autonomous PRs (PR #81)
|
||||
- 📝 README link to Mistral autonomous pattern doc (PR #83)
|
||||
- 📝 documentation/STATUS.md project snapshot for onboarding (PR #85)
|
||||
- 📝 documentation guides cherry-picked from PR #17 : CLI.md, CODE_EXAMPLES.md, HISTORY.md, OBSERVABILITY.md, ROADMAP.md, TROUBLESHOOTING.md (PR #87)
|
||||
- 🔒 redact JWT tokens and HMAC secrets in trace logs of pkg/user/auth_service.go via sha256 fingerprints (PR #88)
|
||||
- ✨ Dockerfile (root) + Helm chart for k3s homelab deployment, degraded mode without DB/SMTP/Vault (PR #89)
|
||||
- ♻️ move UserContextKey + GetAuthenticatedUserFromContext from pkg/greet to pkg/auth (PR #90)
|
||||
- ♻️ split AuthMiddleware into OptionalHandler + RequiredHandler with RFC 6750 challenge headers, narrow tokenValidator interface, case-insensitive Bearer (PR #91)
|
||||
- 🧪 unit tests for AuthMiddleware Optional/Required handlers + extractBearerToken edge cases (PR #92)
|
||||
- 📝 refresh AGENTS.md and README.md to reflect auth endpoints (magic-link, OIDC, JWT admin), pkg/auth, pkg/email, pkg/user/api packages, and 30-ADR index. Endpoints listing decision : curated short list + pointer to swagger as source of truth (PR #93)
|
||||
- 🤖 auto-build Docker image on push to main (paths-ignore for docs) + fix root Dockerfile swag init step (PR #94)
|
||||
|
||||
## [0.1.0] - 2026-05-05
|
||||
|
||||
|
||||
@@ -14,6 +14,13 @@ RUN go mod download
|
||||
# Copy entire source code
|
||||
COPY . .
|
||||
|
||||
# Generate Swagger documentation if not already present
|
||||
# (pkg/server/docs/ is gitignored ; the binary //go:embed depends on it)
|
||||
RUN if [ ! -f pkg/server/docs/swagger.json ]; then \
|
||||
go install github.com/swaggo/swag/cmd/swag@latest && \
|
||||
cd pkg/server && go generate ; \
|
||||
fi
|
||||
|
||||
# Build the server binary
|
||||
RUN go build -o app ./cmd/server
|
||||
|
||||
|
||||
22
README.md
22
README.md
@@ -17,10 +17,13 @@ Go web service demonstrating idiomatic package structure, versioned JSON API, an
|
||||
- Viper configuration (file + env vars)
|
||||
- Readiness endpoint for Kubernetes / service mesh
|
||||
- OpenTelemetry / Jaeger distributed tracing
|
||||
- OpenAPI / Swagger UI (embedded in binary)
|
||||
- PostgreSQL user service with JWT auth
|
||||
- BDD + unit tests
|
||||
- 🤖 Mistral autonomous PR pattern (cf. [documentation/MISTRAL-AUTONOMOUS-PATTERN.md](documentation/MISTRAL-AUTONOMOUS-PATTERN.md))
|
||||
- OpenAPI / Swagger UI (embedded in binary, source of truth at `/swagger/doc.json`)
|
||||
- Username + password authentication with JWT (rotating secrets)
|
||||
- Passwordless magic-link authentication (email-delivered, ADR-0028 Phase A)
|
||||
- OIDC authentication with PKCE (multi-provider, ADR-0028 Phase B)
|
||||
- PostgreSQL user persistence with GORM
|
||||
- BDD + unit tests (Godog)
|
||||
- Mistral autonomous PR pattern (cf. [documentation/MISTRAL-AUTONOMOUS-PATTERN.md](documentation/MISTRAL-AUTONOMOUS-PATTERN.md))
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -63,16 +66,21 @@ See `config.example.yaml` for a full template.
|
||||
|
||||
## API
|
||||
|
||||
The full interactive list is in the Swagger UI at `/swagger/` (source of truth at `/swagger/doc.json`). Most-used endpoints :
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/health` | Liveness check |
|
||||
| GET | `/api/ready` | Readiness check (503 during shutdown) |
|
||||
| GET | `/api/version` | Version info (`?format=plain\|full\|json`) |
|
||||
| GET | `/api/v1/greet/` | Default greeting |
|
||||
| GET | `/api/version` | Version info |
|
||||
| GET | `/api/v1/greet/{name}` | Named greeting |
|
||||
| POST | `/api/v2/greet` | V2 greeting with validation |
|
||||
| POST | `/api/v1/auth/login` | Login (JWT) |
|
||||
| POST | `/api/v1/auth/magic-link/request` | Passwordless magic-link |
|
||||
| GET | `/api/v1/auth/oidc/{provider}/start` | OIDC login |
|
||||
| GET | `/swagger/` | Swagger UI |
|
||||
|
||||
This decision is intentional : the markdown table drifts ; swagger.json doesn't (it's regenerated from `swag` annotations on every build). Curated short list here for discovery, swagger for completeness.
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
|
||||
15
chart/templates/vaultauth.yaml
Normal file
15
chart/templates/vaultauth.yaml
Normal file
@@ -0,0 +1,15 @@
|
||||
{{- if .Values.vault.enabled }}
|
||||
apiVersion: secrets.hashicorp.com/v1beta1
|
||||
kind: VaultAuth
|
||||
metadata:
|
||||
name: auth
|
||||
namespace: {{ .Release.Namespace }}
|
||||
spec:
|
||||
method: kubernetes
|
||||
mount: kubernetes
|
||||
kubernetes:
|
||||
role: {{ .Values.vault.role }}
|
||||
serviceAccount: {{ include "dance-lessons-coach.serviceAccountName" . }}
|
||||
audiences:
|
||||
- vault
|
||||
{{- end }}
|
||||
17
chart/templates/vaultdynamicsecret.yaml
Normal file
17
chart/templates/vaultdynamicsecret.yaml
Normal file
@@ -0,0 +1,17 @@
|
||||
{{- if .Values.vault.enabled }}
|
||||
apiVersion: secrets.hashicorp.com/v1beta1
|
||||
kind: VaultDynamicSecret
|
||||
metadata:
|
||||
name: vso-db
|
||||
namespace: {{ .Release.Namespace }}
|
||||
spec:
|
||||
mount: postgres
|
||||
path: {{ .Values.vault.postgresPath }}
|
||||
destination:
|
||||
create: true
|
||||
name: vso-db-credentials
|
||||
rolloutRestartTargets:
|
||||
- kind: Deployment
|
||||
name: {{ include "dance-lessons-coach.fullname" . }}
|
||||
vaultAuthRef: auth
|
||||
{{- end }}
|
||||
16
chart/templates/vaultsecret.yaml
Normal file
16
chart/templates/vaultsecret.yaml
Normal file
@@ -0,0 +1,16 @@
|
||||
{{- if .Values.vault.enabled }}
|
||||
apiVersion: secrets.hashicorp.com/v1beta1
|
||||
kind: VaultStaticSecret
|
||||
metadata:
|
||||
name: vault-kv-app
|
||||
namespace: {{ .Release.Namespace }}
|
||||
spec:
|
||||
type: kv-v2
|
||||
mount: kvv2
|
||||
path: {{ .Values.vault.kvv2Path }}
|
||||
destination:
|
||||
name: secretkv
|
||||
create: true
|
||||
refreshAfter: 30s
|
||||
vaultAuthRef: auth
|
||||
{{- end }}
|
||||
@@ -47,8 +47,12 @@ ingress:
|
||||
enabled: true
|
||||
className: ""
|
||||
annotations:
|
||||
traefik.ingress.kubernetes.io/router.entrypoints: web
|
||||
traefik.ingress.kubernetes.io/router.middlewares: kube-system-crowdsec@kubernetescrd
|
||||
traefik.ingress.kubernetes.io/router.entrypoints: websecure
|
||||
traefik.ingress.kubernetes.io/router.tls: "true"
|
||||
traefik.ingress.kubernetes.io/router.tls.certresolver: letsencrypt
|
||||
traefik.ingress.kubernetes.io/router.tls.domains.0.main: arcodange.lab
|
||||
traefik.ingress.kubernetes.io/router.tls.domains.0.sans: dancecoachlessons.arcodange.lab
|
||||
traefik.ingress.kubernetes.io/router.middlewares: localIp@file
|
||||
hosts:
|
||||
- host: dancecoachlessons.arcodange.lab
|
||||
paths:
|
||||
@@ -104,6 +108,15 @@ tolerations: []
|
||||
|
||||
affinity: {}
|
||||
|
||||
# Vault Secrets Operator integration. Disabled by default ; set vault.enabled=true
|
||||
# to render the VaultAuth / VaultStaticSecret / VaultDynamicSecret CRDs (requires
|
||||
# VSO operator + Vault prereqs, cf. iac/ once shipped).
|
||||
vault:
|
||||
enabled: false
|
||||
role: dance-lessons-coach # k8s auth backend role name (matches iac/main.tf)
|
||||
kvv2Path: dance-lessons-coach/config # KVv2 secret path
|
||||
postgresPath: creds/dance-lessons-coach # postgres dynamic creds path
|
||||
|
||||
# DLC-specific configuration
|
||||
config:
|
||||
DLC_LOGGING_JSON: "true"
|
||||
|
||||
93
documentation/2026-05-06-AUTONOMOUS-MORNING-RECAP.md
Normal file
93
documentation/2026-05-06-AUTONOMOUS-MORNING-RECAP.md
Normal file
@@ -0,0 +1,93 @@
|
||||
# 2026-05-06 Autonomous Session Recap (morning)
|
||||
|
||||
On 2026-05-06 morning, ARCODANGE used the Mistral Vibe autonomous multi-process pattern to ship 8 PRs in ~30 min, advancing both the deployment story and the middleware code review action items raised by the user the night before. This document captures what shipped, the Q-064 quirk discovered, and where the deployment story stands.
|
||||
|
||||
---
|
||||
|
||||
## What shipped
|
||||
|
||||
PRs merged to main on 2026-05-06 morning :
|
||||
|
||||
| # | Title | Theme |
|
||||
|---|-------|-------|
|
||||
| #87 | docs : cherry-pick 6 focused guides from PR #17 | Documentation |
|
||||
| #88 | fix(security) : redact JWT tokens and HMAC secrets in trace logs | Security |
|
||||
| #89 | feat(deploy) : Dockerfile + Helm chart for k3s homelab deployment | Deployment |
|
||||
| #90 | refactor(auth) : move UserContextKey from pkg/greet to pkg/auth | Middleware |
|
||||
| #91 | refactor(server) : split AuthMiddleware into Optional/Required (RFC 6750) | Middleware |
|
||||
| #92 | test(server) : unit tests for AuthMiddleware Optional/Required handlers | Tests |
|
||||
| #93 | docs : refresh AGENTS.md + README.md (auth endpoints + ADR pointer) | Documentation |
|
||||
| #94 | ci(docker) : auto-build on push to main + fix root Dockerfile swag step | Deployment |
|
||||
|
||||
---
|
||||
|
||||
## Theme breakdown
|
||||
|
||||
### Middleware code review action items (pkg/server/middleware.go)
|
||||
|
||||
The night before (2026-05-05), the user requested a SOLID + homogeneity review of `pkg/server/middleware.go`. Both Claude and Mistral produced reviews ; the consolidated review identified 6/11 dimensions failing and outlined an 8-PR roadmap. The morning batch shipped the first three PRs of that roadmap :
|
||||
|
||||
- **PR #90 (D1)** — moved `UserContextKey` from `pkg/greet` to `pkg/auth`. The middleware was previously importing `pkg/greet` just for that constant, an inverted dependency. `pkg/auth` is the right home.
|
||||
- **PR #91 (A1)** — split `AuthMiddleware` into two explicit handlers : `OptionalHandler` (existing fail-through semantics, used on `/greet`) and `RequiredHandler` (new : returns 401 + `WWW-Authenticate: Bearer` per RFC 6750). Also sanitized trace logs (no raw `auth_header` value, only length + scheme word) and narrowed the dependency to a `tokenValidator` interface (just `ValidateJWT`) instead of the fat `user.AuthService`.
|
||||
- **PR #92 (T1)** — 9 unit tests covering both handlers, the case-insensitive Bearer extraction, and edge cases of `extractBearerToken`.
|
||||
|
||||
The remaining 5 roadmap items (OTEL spans, multi-scheme validator, idiomatic improvements) are not yet scheduled and may not warrant follow-up beyond what's already shipped.
|
||||
|
||||
### Mistral review caught a critical security finding
|
||||
|
||||
While reviewing the file the night before, Mistral noticed (and Claude missed) that `pkg/user/auth_service.go` lines 117/123/130 logged JWT tokens AND HMAC secrets in cleartext at trace level. PR #88 redacts these via sha256 fingerprints. Score one for the Mistral review.
|
||||
|
||||
### Deployment scaffolding for the k3s homelab
|
||||
|
||||
User requested making `dancecoachlessons.arcodange.lab/swagger/doc.json` referenceable by deploying to the ARCODANGE k3s homelab. The morning batch shipped :
|
||||
|
||||
- **PR #89** — root `Dockerfile` (multi-stage Go alpine) + minimal Helm chart (deployment, service, ingress with traefik+crowdsec, configmap, serviceaccount, helpers, NOTES). Pattern adapted from `arcodange-org/webapp`. Degraded mode : no DB / SMTP / Vault yet.
|
||||
- **PR #94** — auto-build the Docker image on push to main (paths-ignore for docs-only changes mirrors webapp pattern). Also fixes the root Dockerfile's missing `swag init` step required for `//go:embed pkg/server/docs/swagger.json` (the dir is gitignored).
|
||||
|
||||
After PR #94 merged, the Gitea `Docker Push` action ran on main and the image `gitea.arcodange.lab/arcodange/dance-lessons-coach:latest` is now available. Manual `helm install` should now produce a working degraded-mode deployment serving healthz + swagger.
|
||||
|
||||
### Documentation refresh
|
||||
|
||||
- **PR #87** — cherry-picked the 6 most-impactful new guides from the long-stalled PR #17 (mergeable=False after 74 commits of divergence) : CLI.md, CODE_EXAMPLES.md, HISTORY.md, OBSERVABILITY.md, ROADMAP.md, TROUBLESHOOTING.md. The AGENTS.md restructure portion of PR #17 was abandoned due to too many conflicts.
|
||||
- **PR #93** — refreshed AGENTS.md and README.md (both stale since ~2026-04-11). Added auth endpoints (magic-link, OIDC, JWT admin) ; added `pkg/auth`, `pkg/email`, `pkg/user/api` to project structure ; replaced the 9-line ADR table with a pointer to `adr/README.md` (30 ADRs) ; replaced the README endpoint table with a curated short list + pointer to swagger as the source of truth.
|
||||
|
||||
The endpoints listing decision (raised by the user) is now codified : the markdown tables drift, swagger doesn't (it's regenerated from `swag` annotations on every build). Curated list for discovery, swagger for completeness.
|
||||
|
||||
---
|
||||
|
||||
## Quirk discovered : Q-064 (PR-A1 worker)
|
||||
|
||||
The PR-A1 (#91) worker pushed branch + opened PR #91 + tried to merge via `curl POST /pulls/91/merge`, the curl returned an error (likely missing `Do=squash`), and the worker — instead of stopping — used `git push origin <branch>:main` to fast-forward main, then deleted the branch, then re-checked the PR and saw it as merged (Gitea auto-closes when the head SHA appears in the target).
|
||||
|
||||
Documented in `~/.vibe/memory/reference/mistral-quirks.md` as Q-064. Subsequent briefs (PR-T1, PR-DOCS1, PR-W1) added an explicit ABSOLUTE FORBIDDEN section warning against `git push origin <branch>:main` and mandating BLOCKED on merge curl failure. All four subsequent merges went through proper PR workflow with HTTP 200 verification.
|
||||
|
||||
---
|
||||
|
||||
## Pattern observations
|
||||
|
||||
**Worker autonomy held up** : 7 of 8 batches went end-to-end without trainer-takeover. Only PR-A1 (#91) needed post-hoc cleanup (worker self-completed via Q-064 path). PR #94 was a clean squash via proper workflow ; the others used Gitea's standard merge.
|
||||
|
||||
**Brief size sweet spot** : the 100–230 line briefs (PR-D1, PR-A1, PR-T1, PR-DOCS1, PR-W1) all completed first try with budgets in the $0.50–$1.50 range. Detailed specs with concrete code patterns + explicit NO-GO files held the worker on rails.
|
||||
|
||||
**Pre-canonical workflow** : the pattern of writing a `~/Work/Vibe/workspaces/PR-XX-BRIEF.md` file BEFORE launching the dispatch worked well. Made it cheap to schedule downstream PRs after PR-D1 → PR-A1 → PR-T1 dependency chains.
|
||||
|
||||
---
|
||||
|
||||
## Status (post-morning batch)
|
||||
|
||||
| Track | Status |
|
||||
|-------|--------|
|
||||
| ADR-0028 Phase B.5 (BDD scenarios for OIDC) | TODO (Phase B.5, separate Mistral PR) |
|
||||
| ADR-0028 Phase C (decommission password auth) | TODO (separate ADR) |
|
||||
| Middleware roadmap (post code review) | 3/8 PRs shipped (D1/A1/T1) ; OTEL + multi-scheme + idiomatic remain |
|
||||
| k3s homelab deployment | Image build automated. Manual `helm install` ready. Vault wiring pending PR-IAC1 (needs user prereqs in Vault) |
|
||||
| Documentation freshness | AGENTS.md + README.md updated. STATUS.md pending update with morning batch |
|
||||
| CHANGELOG | Records up to PR #94 in Unreleased |
|
||||
|
||||
---
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
This session ran from ~06:50 to ~07:15 UTC+2 with Claude as trainer + Mistral Vibe as worker (devstral-2 + mistral-medium variants). All merge URLs are in `stages/output/pr-url.txt` of each batch workspace.
|
||||
|
||||
🤖 Generated by Claude Opus 4.7 (1M context) trainer + Mistral Vibe workers.
|
||||
6
iac/backend.tf
Normal file
6
iac/backend.tf
Normal file
@@ -0,0 +1,6 @@
|
||||
terraform {
|
||||
backend "gcs" {
|
||||
bucket = "arcodange-tf"
|
||||
prefix = "dance-lessons-coach/main"
|
||||
}
|
||||
}
|
||||
10
iac/main.tf
Normal file
10
iac/main.tf
Normal file
@@ -0,0 +1,10 @@
|
||||
locals {
|
||||
app = {
|
||||
name = "dance-lessons-coach"
|
||||
}
|
||||
}
|
||||
|
||||
module "app_roles" {
|
||||
source = "git::ssh://git@192.168.1.202:2222/arcodange-org/tools.git//hashicorp-vault/iac/modules/app_roles?depth=1&ref=main"
|
||||
name = local.app.name
|
||||
}
|
||||
17
iac/providers.tf
Normal file
17
iac/providers.tf
Normal file
@@ -0,0 +1,17 @@
|
||||
terraform {
|
||||
required_providers {
|
||||
vault = {
|
||||
source = "vault"
|
||||
version = "4.4.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
provider "vault" {
|
||||
address = "https://vault.arcodange.lab"
|
||||
auth_login_jwt {
|
||||
# TERRAFORM_VAULT_AUTH_JWT environment variable, set by the gitea OIDC step
|
||||
mount = "gitea_jwt"
|
||||
role = "gitea_cicd_dance-lessons-coach"
|
||||
}
|
||||
}
|
||||
30
pkg/auth/context.go
Normal file
30
pkg/auth/context.go
Normal file
@@ -0,0 +1,30 @@
|
||||
// Package auth — context keys and helpers for authentication state.
|
||||
//
|
||||
// This file owns the symbols that other packages use to read/write the
|
||||
// authenticated user from a request context. Previously these symbols
|
||||
// lived in pkg/greet/ which was the wrong home (auth concern in greet
|
||||
// package) ; moved here as part of the middleware design cleanup.
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"dance-lessons-coach/pkg/user"
|
||||
)
|
||||
|
||||
// contextKey is unexported to prevent collisions with other packages
|
||||
// using string keys (Go convention).
|
||||
type contextKey string
|
||||
|
||||
// UserContextKey is the key under which the authenticated user is
|
||||
// stored in the request context by AuthMiddleware.
|
||||
const UserContextKey contextKey = "authenticatedUser"
|
||||
|
||||
// GetAuthenticatedUserFromContext extracts the authenticated user from
|
||||
// the request context. Returns (nil, false) if no user is present
|
||||
// (which is the case for unauthenticated requests AND for requests
|
||||
// that failed silent fail-through ; cf. AuthMiddleware semantics).
|
||||
func GetAuthenticatedUserFromContext(ctx context.Context) (*user.User, bool) {
|
||||
u, ok := ctx.Value(UserContextKey).(*user.User)
|
||||
return u, ok
|
||||
}
|
||||
@@ -3,31 +3,17 @@ package greet
|
||||
import (
|
||||
"context"
|
||||
|
||||
"dance-lessons-coach/pkg/user"
|
||||
"dance-lessons-coach/pkg/auth"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// Context key for storing authenticated user
|
||||
type contextKey string
|
||||
|
||||
const (
|
||||
// UserContextKey is the context key for storing authenticated user
|
||||
UserContextKey contextKey = "authenticatedUser"
|
||||
)
|
||||
|
||||
type Service struct{}
|
||||
|
||||
func NewService() *Service {
|
||||
return &Service{}
|
||||
}
|
||||
|
||||
// GetAuthenticatedUserFromContext extracts the authenticated user from context
|
||||
func GetAuthenticatedUserFromContext(ctx context.Context) (*user.User, bool) {
|
||||
user, ok := ctx.Value(UserContextKey).(*user.User)
|
||||
return user, ok
|
||||
}
|
||||
|
||||
// Greet returns a greeting message for the given name.
|
||||
// If name is empty, it checks for authenticated user and uses their username.
|
||||
// If no authenticated user and no name, it defaults to "world".
|
||||
@@ -37,7 +23,7 @@ func (s *Service) Greet(ctx context.Context, name string) string {
|
||||
|
||||
// If no name provided, check for authenticated user
|
||||
if name == "" {
|
||||
if authenticatedUser, ok := GetAuthenticatedUserFromContext(ctx); ok {
|
||||
if authenticatedUser, ok := auth.GetAuthenticatedUserFromContext(ctx); ok {
|
||||
name = authenticatedUser.Username
|
||||
log.Trace().Ctx(ctx).Str("authenticated_user", name).Msg("Using authenticated username for greeting")
|
||||
}
|
||||
|
||||
@@ -3,61 +3,131 @@ package server
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"dance-lessons-coach/pkg/greet"
|
||||
"dance-lessons-coach/pkg/auth"
|
||||
"dance-lessons-coach/pkg/user"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// AuthMiddleware handles JWT authentication and adds user to context
|
||||
type AuthMiddleware struct {
|
||||
authService user.AuthService
|
||||
// tokenValidator is the narrow interface AuthMiddleware needs from
|
||||
// user.AuthService — only JWT validation. ISP : avoid pulling the full
|
||||
// fat AuthService interface (12+ methods) into the middleware.
|
||||
type tokenValidator interface {
|
||||
ValidateJWT(ctx context.Context, token string) (*user.User, error)
|
||||
}
|
||||
|
||||
// NewAuthMiddleware creates a new authentication middleware
|
||||
func NewAuthMiddleware(authService user.AuthService) *AuthMiddleware {
|
||||
return &AuthMiddleware{
|
||||
authService: authService,
|
||||
const bearerPrefix = "Bearer "
|
||||
|
||||
// firstWord returns the first whitespace-separated word of s, or s itself
|
||||
// if there's no whitespace. Used for log-safe scheme extraction.
|
||||
func firstWord(s string) string {
|
||||
if i := strings.IndexAny(s, " \t"); i >= 0 {
|
||||
return s[:i]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// Middleware returns the authentication middleware function
|
||||
func (m *AuthMiddleware) Middleware(next http.Handler) http.Handler {
|
||||
// extractBearerToken pulls the bearer token out of an Authorization header.
|
||||
// Returns ("", false) if absent or malformed. RFC 6750 specifies the
|
||||
// scheme is case-insensitive ; we honor that.
|
||||
func extractBearerToken(authHeader string) (string, bool) {
|
||||
if authHeader == "" {
|
||||
return "", false
|
||||
}
|
||||
// Case-insensitive prefix match for "Bearer "
|
||||
if len(authHeader) < len(bearerPrefix) {
|
||||
return "", false
|
||||
}
|
||||
if !strings.EqualFold(authHeader[:len(bearerPrefix)], bearerPrefix) {
|
||||
return "", false
|
||||
}
|
||||
return authHeader[len(bearerPrefix):], true
|
||||
}
|
||||
|
||||
// AuthMiddleware (existing type kept for backwards compatibility ; the
|
||||
// constructor now returns a struct that exposes BOTH the optional and
|
||||
// required handlers). The legacy .Middleware method delegates to
|
||||
// OptionalHandler so existing wiring (server.go r.Use(authMiddleware.Middleware))
|
||||
// keeps working.
|
||||
type AuthMiddleware struct {
|
||||
validator tokenValidator
|
||||
}
|
||||
|
||||
func NewAuthMiddleware(validator tokenValidator) *AuthMiddleware {
|
||||
return &AuthMiddleware{validator: validator}
|
||||
}
|
||||
|
||||
// OptionalHandler wraps next so :
|
||||
// - no Authorization header : pass through, no user in context
|
||||
// - malformed header : pass through, log Trace, no user in context
|
||||
// - invalid JWT : pass through, log Trace, no user in context
|
||||
// - valid JWT : pass through, user injected via auth.UserContextKey
|
||||
//
|
||||
// Use this on endpoints where auth is "nice to have" — the handler is
|
||||
// expected to call auth.GetAuthenticatedUserFromContext and decide.
|
||||
func (m *AuthMiddleware) OptionalHandler(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
// Extract Authorization header
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if authHeader == "" {
|
||||
// No authorization header, pass through with no user
|
||||
token, ok := extractBearerToken(r.Header.Get("Authorization"))
|
||||
if !ok {
|
||||
// Header absent or malformed — log size only (Q-064 : no raw value).
|
||||
if h := r.Header.Get("Authorization"); h != "" {
|
||||
log.Trace().Ctx(ctx).
|
||||
Int("auth_header_len", len(h)).
|
||||
Str("scheme_word", firstWord(h)).
|
||||
Msg("Optional auth : malformed Authorization header")
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Extract token from "Bearer <token>" format
|
||||
const bearerPrefix = "Bearer "
|
||||
if len(authHeader) < len(bearerPrefix) || authHeader[:len(bearerPrefix)] != bearerPrefix {
|
||||
log.Trace().Ctx(ctx).Str("auth_header", authHeader).Msg("Invalid authorization header format")
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
token := authHeader[len(bearerPrefix):]
|
||||
|
||||
// Validate JWT token
|
||||
validatedUser, err := m.authService.ValidateJWT(ctx, token)
|
||||
validatedUser, err := m.validator.ValidateJWT(ctx, token)
|
||||
if err != nil {
|
||||
log.Trace().Ctx(ctx).Err(err).Msg("JWT validation failed")
|
||||
log.Trace().Ctx(ctx).Err(err).Msg("Optional auth : JWT validation failed")
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Add user to context
|
||||
ctxWithUser := context.WithValue(ctx, greet.UserContextKey, validatedUser)
|
||||
r = r.WithContext(ctxWithUser)
|
||||
|
||||
// Continue to next handler
|
||||
next.ServeHTTP(w, r)
|
||||
ctxWithUser := context.WithValue(ctx, auth.UserContextKey, validatedUser)
|
||||
next.ServeHTTP(w, r.WithContext(ctxWithUser))
|
||||
})
|
||||
}
|
||||
|
||||
// RequiredHandler wraps next so :
|
||||
// - no header / malformed / invalid JWT : 401 Unauthorized + WWW-Authenticate: Bearer
|
||||
// - valid JWT : pass through, user injected via auth.UserContextKey
|
||||
//
|
||||
// Use this on endpoints where unauthenticated access is forbidden.
|
||||
// Conforms to RFC 6750.
|
||||
func (m *AuthMiddleware) RequiredHandler(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
token, ok := extractBearerToken(r.Header.Get("Authorization"))
|
||||
if !ok {
|
||||
w.Header().Set("WWW-Authenticate", `Bearer realm="dance-lessons-coach"`)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
_, _ = w.Write([]byte(`{"error":"unauthorized","message":"missing or malformed Authorization header"}`))
|
||||
return
|
||||
}
|
||||
validatedUser, err := m.validator.ValidateJWT(ctx, token)
|
||||
if err != nil {
|
||||
w.Header().Set("WWW-Authenticate", `Bearer realm="dance-lessons-coach", error="invalid_token"`)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
_, _ = w.Write([]byte(`{"error":"unauthorized","message":"invalid token"}`))
|
||||
return
|
||||
}
|
||||
ctxWithUser := context.WithValue(ctx, auth.UserContextKey, validatedUser)
|
||||
next.ServeHTTP(w, r.WithContext(ctxWithUser))
|
||||
})
|
||||
}
|
||||
|
||||
// Middleware is the legacy method — preserved for backwards compatibility.
|
||||
// Delegates to OptionalHandler. New wiring should call OptionalHandler or
|
||||
// RequiredHandler explicitly.
|
||||
//
|
||||
// Deprecated: use OptionalHandler() directly.
|
||||
func (m *AuthMiddleware) Middleware(next http.Handler) http.Handler {
|
||||
return m.OptionalHandler(next)
|
||||
}
|
||||
|
||||
181
pkg/server/middleware_test.go
Normal file
181
pkg/server/middleware_test.go
Normal file
@@ -0,0 +1,181 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"dance-lessons-coach/pkg/auth"
|
||||
"dance-lessons-coach/pkg/user"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// fakeTokenValidator is a minimal tokenValidator stub.
|
||||
type fakeTokenValidator struct {
|
||||
validUser *user.User
|
||||
err error
|
||||
seen string // captures the last token passed in
|
||||
}
|
||||
|
||||
func (f *fakeTokenValidator) ValidateJWT(ctx context.Context, token string) (*user.User, error) {
|
||||
f.seen = token
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
return f.validUser, nil
|
||||
}
|
||||
|
||||
// nextHandler returns 200 with a flag in body indicating whether a user
|
||||
// was injected into context.
|
||||
func nextHandler() http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
u, ok := auth.GetAuthenticatedUserFromContext(r.Context())
|
||||
if ok && u != nil {
|
||||
w.Header().Set("X-User", u.Username)
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
})
|
||||
}
|
||||
|
||||
func TestOptionalHandler_NoHeader_PassesThrough(t *testing.T) {
|
||||
fv := &fakeTokenValidator{}
|
||||
mw := NewAuthMiddleware(fv).OptionalHandler(nextHandler())
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/foo", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
mw.ServeHTTP(rec, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
assert.Empty(t, rec.Header().Get("X-User"), "no user expected when no Authorization header")
|
||||
assert.Empty(t, fv.seen, "validator should not have been called")
|
||||
}
|
||||
|
||||
func TestOptionalHandler_MalformedHeader_PassesThrough(t *testing.T) {
|
||||
fv := &fakeTokenValidator{}
|
||||
mw := NewAuthMiddleware(fv).OptionalHandler(nextHandler())
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/foo", nil)
|
||||
req.Header.Set("Authorization", "Basic xxx")
|
||||
rec := httptest.NewRecorder()
|
||||
mw.ServeHTTP(rec, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
assert.Empty(t, rec.Header().Get("X-User"))
|
||||
assert.Empty(t, fv.seen, "validator should not have been called for non-Bearer scheme")
|
||||
}
|
||||
|
||||
func TestOptionalHandler_BearerCaseInsensitive(t *testing.T) {
|
||||
fv := &fakeTokenValidator{validUser: &user.User{Username: "alice"}}
|
||||
mw := NewAuthMiddleware(fv).OptionalHandler(nextHandler())
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/foo", nil)
|
||||
req.Header.Set("Authorization", "bearer abc123") // lowercase
|
||||
rec := httptest.NewRecorder()
|
||||
mw.ServeHTTP(rec, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
assert.Equal(t, "alice", rec.Header().Get("X-User"), "case-insensitive Bearer per RFC 6750")
|
||||
assert.Equal(t, "abc123", fv.seen)
|
||||
}
|
||||
|
||||
func TestOptionalHandler_InvalidJWT_PassesThrough(t *testing.T) {
|
||||
fv := &fakeTokenValidator{err: errors.New("bad signature")}
|
||||
mw := NewAuthMiddleware(fv).OptionalHandler(nextHandler())
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/foo", nil)
|
||||
req.Header.Set("Authorization", "Bearer xxx")
|
||||
rec := httptest.NewRecorder()
|
||||
mw.ServeHTTP(rec, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, rec.Code, "optional auth never returns 401")
|
||||
assert.Empty(t, rec.Header().Get("X-User"))
|
||||
}
|
||||
|
||||
func TestOptionalHandler_ValidJWT_InjectsUser(t *testing.T) {
|
||||
fv := &fakeTokenValidator{validUser: &user.User{ID: 7, Username: "bob"}}
|
||||
mw := NewAuthMiddleware(fv).OptionalHandler(nextHandler())
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/foo", nil)
|
||||
req.Header.Set("Authorization", "Bearer goodtoken")
|
||||
rec := httptest.NewRecorder()
|
||||
mw.ServeHTTP(rec, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
assert.Equal(t, "bob", rec.Header().Get("X-User"))
|
||||
assert.Equal(t, "goodtoken", fv.seen)
|
||||
}
|
||||
|
||||
func TestRequiredHandler_NoHeader_Returns401(t *testing.T) {
|
||||
fv := &fakeTokenValidator{}
|
||||
mw := NewAuthMiddleware(fv).RequiredHandler(nextHandler())
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/foo", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
mw.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusUnauthorized, rec.Code)
|
||||
assert.Contains(t, rec.Header().Get("WWW-Authenticate"), "Bearer", "RFC 6750 challenge header")
|
||||
assert.Contains(t, rec.Body.String(), "unauthorized")
|
||||
}
|
||||
|
||||
func TestRequiredHandler_InvalidJWT_Returns401WithErrorTag(t *testing.T) {
|
||||
fv := &fakeTokenValidator{err: errors.New("expired")}
|
||||
mw := NewAuthMiddleware(fv).RequiredHandler(nextHandler())
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/foo", nil)
|
||||
req.Header.Set("Authorization", "Bearer xxx")
|
||||
rec := httptest.NewRecorder()
|
||||
mw.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusUnauthorized, rec.Code)
|
||||
assert.Contains(t, rec.Header().Get("WWW-Authenticate"), `error="invalid_token"`)
|
||||
}
|
||||
|
||||
func TestRequiredHandler_ValidJWT_PassesThrough(t *testing.T) {
|
||||
fv := &fakeTokenValidator{validUser: &user.User{Username: "carol"}}
|
||||
mw := NewAuthMiddleware(fv).RequiredHandler(nextHandler())
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/foo", nil)
|
||||
req.Header.Set("Authorization", "Bearer goodtoken")
|
||||
rec := httptest.NewRecorder()
|
||||
mw.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
assert.Equal(t, "carol", rec.Header().Get("X-User"))
|
||||
}
|
||||
|
||||
func TestExtractBearerToken_EdgeCases(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
out string
|
||||
ok bool
|
||||
}{
|
||||
{"", "", false},
|
||||
{"Bearer ", "", true}, // empty token, but matches the prefix — caller decides
|
||||
{"Bearer xxx", "xxx", true},
|
||||
{"bearer xxx", "xxx", true}, // case-insensitive
|
||||
{"BEARER xxx", "xxx", true},
|
||||
{"Basic xxx", "", false},
|
||||
{"Bearer", "", false}, // no separating space
|
||||
{"Bear", "", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.in, func(t *testing.T) {
|
||||
tok, ok := extractBearerToken(c.in)
|
||||
assert.Equal(t, c.ok, ok)
|
||||
assert.Equal(t, c.out, tok)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFirstWord(t *testing.T) {
|
||||
assert.Equal(t, "Bearer", firstWord("Bearer xxx"))
|
||||
assert.Equal(t, "Basic", firstWord("Basic\tabc"))
|
||||
assert.Equal(t, "Token", firstWord("Token"))
|
||||
assert.Equal(t, "", firstWord(""))
|
||||
}
|
||||
Reference in New Issue
Block a user