Initial commit
This commit is contained in:
commit
afed023152
8152 files changed
+2086461
No files matched your search
@@ -0,0 +1,221 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.k6n.net/mats/fullstack-template/internal/auth"
|
||||
)
|
||||
|
||||
// AuthDeps bundles the OIDC collaborators; nil Auth in Deps disables the
|
||||
// /api/auth routes and the /api/v1 guard (standalone mode).
|
||||
type AuthDeps struct {
|
||||
Client *auth.Client
|
||||
Signer *auth.Signer
|
||||
SessionTTL time.Duration // zero means the 24h default
|
||||
}
|
||||
|
||||
const defaultSessionTTL = 24 * time.Hour
|
||||
|
||||
type authHandler struct {
|
||||
client *auth.Client
|
||||
signer *auth.Signer
|
||||
sessionTTL time.Duration
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// sessionUser is the public shape of the signed-in identity.
|
||||
type sessionUser struct {
|
||||
Sub string `json:"sub"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
Roles []string `json:"roles"`
|
||||
}
|
||||
|
||||
// login starts the browser flow: it parks a login transaction (state,
|
||||
// nonce, PKCE verifier, return path) in a short-lived cookie and redirects
|
||||
// to the issuer's authorization endpoint.
|
||||
func (h *authHandler) login(w http.ResponseWriter, r *http.Request) {
|
||||
tx, err := auth.NewLoginTx(sanitizeReturnTo(r.URL.Query().Get("returnTo")))
|
||||
if err != nil {
|
||||
h.fail(w, err)
|
||||
return
|
||||
}
|
||||
cookie, err := h.signer.NewLoginTxCookie(tx, requestSecure(r))
|
||||
if err != nil {
|
||||
h.fail(w, err)
|
||||
return
|
||||
}
|
||||
http.SetCookie(w, cookie)
|
||||
http.Redirect(w, r, h.client.AuthCodeURL(tx, redirectURI(r)), http.StatusFound)
|
||||
}
|
||||
|
||||
// callback completes the flow: it checks state, exchanges the code with the
|
||||
// PKCE verifier, verifies the ID token (signature, iss/aud/exp, nonce), and
|
||||
// installs the session cookie. Any failure redirects back to the app with
|
||||
// an auth_error code — the SPA surfaces it; details stay in server logs.
|
||||
func (h *authHandler) callback(w http.ResponseWriter, r *http.Request) {
|
||||
secure := requestSecure(r)
|
||||
http.SetCookie(w, auth.Expired(auth.CookieLoginTx, secure))
|
||||
|
||||
fail := func(code string) {
|
||||
http.Redirect(w, r, "/?auth_error="+url.QueryEscape(code), http.StatusFound)
|
||||
}
|
||||
|
||||
// An issuer-reported error (user denied, interaction required, …) is
|
||||
// more useful than our own missing-transaction complaint.
|
||||
if e := r.URL.Query().Get("error"); e != "" {
|
||||
fail(e)
|
||||
return
|
||||
}
|
||||
tx, ok := h.readLoginTx(r)
|
||||
if !ok {
|
||||
fail("expired_login")
|
||||
return
|
||||
}
|
||||
q := r.URL.Query()
|
||||
if q.Get("state") != tx.State {
|
||||
fail("state_mismatch")
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.client.CompleteCode(r.Context(), q.Get("code"), redirectURI(r), tx)
|
||||
if err != nil {
|
||||
h.logger.Error("oidc code exchange failed", "err", err)
|
||||
fail("code_exchange")
|
||||
return
|
||||
}
|
||||
|
||||
cookie, err := h.signer.NewSessionCookie(
|
||||
auth.Session{Sub: user.Sub, Name: user.Name, Email: user.Email, Roles: user.Roles},
|
||||
h.sessionTTL, secure)
|
||||
if err != nil {
|
||||
h.fail(w, err)
|
||||
return
|
||||
}
|
||||
http.SetCookie(w, cookie)
|
||||
http.Redirect(w, r, tx.ReturnTo, http.StatusFound)
|
||||
}
|
||||
|
||||
// session reports who is signed in; it answers 200 either way so the SPA
|
||||
// can poll it without treating "anonymous" as an error.
|
||||
func (h *authHandler) session(w http.ResponseWriter, r *http.Request) {
|
||||
type sessionResponse struct {
|
||||
Authenticated bool `json:"authenticated"`
|
||||
User *sessionUser `json:"user"`
|
||||
}
|
||||
res := sessionResponse{}
|
||||
if sess, ok := CurrentSession(h.signer, r); ok {
|
||||
res.Authenticated = true
|
||||
res.User = &sessionUser{Sub: sess.Sub, Name: sess.Name, Email: sess.Email, Roles: sess.Roles}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, res)
|
||||
}
|
||||
|
||||
// logout clears the session cookie.
|
||||
func (h *authHandler) logout(w http.ResponseWriter, r *http.Request) {
|
||||
http.SetCookie(w, auth.Expired(auth.CookieSession, requestSecure(r)))
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// readLoginTx loads and validates the login-transaction cookie.
|
||||
func (h *authHandler) readLoginTx(r *http.Request) (auth.LoginTx, bool) {
|
||||
cookie, err := r.Cookie(auth.CookieLoginTx)
|
||||
if err != nil {
|
||||
return auth.LoginTx{}, false
|
||||
}
|
||||
var tx auth.LoginTx
|
||||
if h.signer.Open(cookie.Value, &tx) != nil || !tx.Valid() {
|
||||
return auth.LoginTx{}, false
|
||||
}
|
||||
return tx, true
|
||||
}
|
||||
|
||||
// CurrentSession returns the valid session behind the request, if any. It
|
||||
// is the single read path shared by the session endpoint and requireAuth.
|
||||
func CurrentSession(signer *auth.Signer, r *http.Request) (auth.Session, bool) {
|
||||
cookie, err := r.Cookie(auth.CookieSession)
|
||||
if err != nil {
|
||||
return auth.Session{}, false
|
||||
}
|
||||
var sess auth.Session
|
||||
if signer.Open(cookie.Value, &sess) != nil || !sess.Valid() {
|
||||
return auth.Session{}, false
|
||||
}
|
||||
return sess, true
|
||||
}
|
||||
|
||||
// requireAuth guards the business API behind a valid session cookie. It is
|
||||
// installed inside the chain, so unknown /api/v1/* routes answer 401, not
|
||||
// 404 — route existence is not public information.
|
||||
func requireAuth(signer *auth.Signer) middleware {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, "/api/v1/") {
|
||||
if _, ok := CurrentSession(signer, r); !ok {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized",
|
||||
"sign in via /api/auth/login to call the API")
|
||||
return
|
||||
}
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// redirectURI rebuilds the public origin the browser used, so the issuer
|
||||
// sends the user back through the same host that started the flow — the
|
||||
// vite proxy (localhost or *.preview.k6n.net) or the API itself. The proxy
|
||||
// sets X-Forwarded-Host/Proto; direct requests fall back to r.Host/TLS.
|
||||
func redirectURI(r *http.Request) string {
|
||||
return requestScheme(r) + "://" + requestHost(r) + "/api/auth/callback"
|
||||
}
|
||||
|
||||
// requestSecure reports whether the browser reached us over https.
|
||||
func requestSecure(r *http.Request) bool { return requestScheme(r) == "https" }
|
||||
|
||||
// requestScheme resolves the scheme from X-Forwarded-Proto (first hop) or
|
||||
// the connection itself.
|
||||
func requestScheme(r *http.Request) string {
|
||||
if proto := forwardedValue(r.Header.Get("X-Forwarded-Proto")); proto != "" {
|
||||
return proto
|
||||
}
|
||||
if r.TLS != nil {
|
||||
return "https"
|
||||
}
|
||||
return "http"
|
||||
}
|
||||
|
||||
// requestHost resolves the host from X-Forwarded-Host (first hop) or Host.
|
||||
func requestHost(r *http.Request) string {
|
||||
if host := forwardedValue(r.Header.Get("X-Forwarded-Host")); host != "" {
|
||||
return host
|
||||
}
|
||||
return r.Host
|
||||
}
|
||||
|
||||
// forwardedValue returns the first value of a forwarded header, which is
|
||||
// what our own proxy appended.
|
||||
func forwardedValue(v string) string {
|
||||
first, _, _ := strings.Cut(v, ",")
|
||||
return strings.TrimSpace(first)
|
||||
}
|
||||
|
||||
// sanitizeReturnTo keeps post-login redirects inside the app: relative
|
||||
// paths only, no protocol-relative //host or scheme tricks.
|
||||
func sanitizeReturnTo(v string) string {
|
||||
if v == "" || !strings.HasPrefix(v, "/") || strings.HasPrefix(v, "//") ||
|
||||
strings.ContainsAny(v, "\r\n\\") {
|
||||
return "/"
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// fail maps unexpected internal errors to the standard envelope.
|
||||
func (h *authHandler) fail(w http.ResponseWriter, err error) {
|
||||
h.logger.Error("auth handler failed", "err", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "an internal error occurred")
|
||||
}
|
||||
@@ -0,0 +1,400 @@
|
||||
package api_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.k6n.net/mats/fullstack-template/internal/api"
|
||||
"git.k6n.net/mats/fullstack-template/internal/auth"
|
||||
"git.k6n.net/mats/fullstack-template/internal/items"
|
||||
)
|
||||
|
||||
// fakeIDP is a minimal OpenID Connect issuer: discovery, a token endpoint
|
||||
// that answers any authorization code with a freshly signed RS256 ID token,
|
||||
// and the matching JWKS. Enough to drive the real client end to end.
|
||||
|
||||
type fakeIDP struct {
|
||||
server *httptest.Server
|
||||
key *rsa.PrivateKey
|
||||
clientID string
|
||||
// nonceForCode records the nonce embedded in the token issued per code.
|
||||
nonceForCode map[string]string
|
||||
}
|
||||
|
||||
func newFakeIDP(t *testing.T) *fakeIDP {
|
||||
t.Helper()
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatalf("generate rsa key: %v", err)
|
||||
}
|
||||
idp := &fakeIDP{key: key, clientID: "test-client", nonceForCode: map[string]string{}}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSONString(w, map[string]any{
|
||||
"issuer": idp.server.URL,
|
||||
"authorization_endpoint": idp.server.URL + "/authorize",
|
||||
"token_endpoint": idp.server.URL + "/token",
|
||||
"jwks_uri": idp.server.URL + "/keys",
|
||||
"id_token_signing_alg_values_supported": []string{"RS256"},
|
||||
})
|
||||
})
|
||||
mux.HandleFunc("GET /keys", func(w http.ResponseWriter, _ *http.Request) {
|
||||
pub := idp.key.PublicKey
|
||||
writeJSONString(w, map[string]any{
|
||||
"keys": []map[string]any{{
|
||||
"kty": "RSA", "kid": "test-key", "alg": "RS256",
|
||||
"n": base64.RawURLEncoding.EncodeToString(pub.N.Bytes()),
|
||||
"e": base64.RawURLEncoding.EncodeToString([]byte{1, 0, 1}),
|
||||
}},
|
||||
})
|
||||
})
|
||||
mux.HandleFunc("POST /token", func(w http.ResponseWriter, r *http.Request) {
|
||||
code := r.FormValue("code")
|
||||
claims := map[string]any{
|
||||
"iss": idp.server.URL,
|
||||
"sub": "user-1",
|
||||
"aud": idp.clientID,
|
||||
"exp": time.Now().Add(time.Hour).Unix(),
|
||||
"iat": time.Now().Unix(),
|
||||
"name": "Test User",
|
||||
"email": "test@example.com",
|
||||
"roles": []string{"admin"},
|
||||
}
|
||||
// The authorize step would have bound the nonce to this code; the
|
||||
// test injects it via nonceForCode before calling the callback.
|
||||
if nonce := idp.nonceForCode[code]; nonce != "" {
|
||||
claims["nonce"] = nonce
|
||||
}
|
||||
writeJSONString(w, map[string]any{
|
||||
"access_token": "at-" + code,
|
||||
"token_type": "Bearer",
|
||||
"id_token": idp.sign(t, claims),
|
||||
})
|
||||
})
|
||||
idp.server = httptest.NewServer(mux)
|
||||
t.Cleanup(idp.server.Close)
|
||||
return idp
|
||||
}
|
||||
|
||||
// sign produces a compact RS256 JWS for the claims.
|
||||
func (f *fakeIDP) sign(t *testing.T, claims map[string]any) string {
|
||||
t.Helper()
|
||||
header, err := json.Marshal(map[string]string{"alg": "RS256", "typ": "JWT", "kid": "test-key"})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal header: %v", err)
|
||||
}
|
||||
payload, err := json.Marshal(claims)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal claims: %v", err)
|
||||
}
|
||||
signingInput := base64.RawURLEncoding.EncodeToString(header) + "." +
|
||||
base64.RawURLEncoding.EncodeToString(payload)
|
||||
digest := sha256.Sum256([]byte(signingInput))
|
||||
sig, err := rsa.SignPKCS1v15(rand.Reader, f.key, crypto.SHA256, digest[:])
|
||||
if err != nil {
|
||||
t.Fatalf("sign: %v", err)
|
||||
}
|
||||
return signingInput + "." + base64.RawURLEncoding.EncodeToString(sig)
|
||||
}
|
||||
|
||||
func writeJSONString(w http.ResponseWriter, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
// newAuthTestServer builds the full api stack with OIDC pointed at idp.
|
||||
func newAuthTestServer(t *testing.T, idp *fakeIDP) http.Handler {
|
||||
t.Helper()
|
||||
client, err := auth.New(context.Background(), auth.Config{
|
||||
Issuer: idp.server.URL,
|
||||
ClientID: idp.clientID,
|
||||
ClientSecret: "test-secret",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("auth.New: %v", err)
|
||||
}
|
||||
return api.New(api.Deps{
|
||||
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||
Items: items.NewService(newFakeRepo()),
|
||||
DB: stubPinger{},
|
||||
AllowedOrigins: []string{"http://localhost:5173"},
|
||||
Auth: &api.AuthDeps{
|
||||
Client: client,
|
||||
Signer: auth.NewSigner("test-signing-secret"),
|
||||
SessionTTL: time.Hour,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// startLogin drives GET /api/auth/login and returns (tx cookie, parsed
|
||||
// authorize URL).
|
||||
func startLogin(t *testing.T, h http.Handler, target string) (*http.Cookie, *url.URL) {
|
||||
t.Helper()
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, target, nil)
|
||||
req.Host = "localhost:5173"
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusFound {
|
||||
t.Fatalf("login = %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var txCookie *http.Cookie
|
||||
for _, c := range rec.Result().Cookies() {
|
||||
if c.Name == auth.CookieLoginTx {
|
||||
txCookie = c
|
||||
}
|
||||
}
|
||||
if txCookie == nil {
|
||||
t.Fatalf("login set no %s cookie", auth.CookieLoginTx)
|
||||
}
|
||||
authURL, err := url.Parse(rec.Header().Get("Location"))
|
||||
if err != nil {
|
||||
t.Fatalf("parse authorize url: %v", err)
|
||||
}
|
||||
return txCookie, authURL
|
||||
}
|
||||
|
||||
func TestOIDCLoginFlow(t *testing.T) {
|
||||
idp := newFakeIDP(t)
|
||||
h := newAuthTestServer(t, idp)
|
||||
|
||||
// Anonymous: session reports unauthenticated, /api/v1 is closed.
|
||||
rec := do(t, h, http.MethodGet, "/api/auth/session", "")
|
||||
var sess struct {
|
||||
Authenticated bool `json:"authenticated"`
|
||||
User any `json:"user"`
|
||||
}
|
||||
decodeBody(t, rec, &sess)
|
||||
if sess.Authenticated || sess.User != nil {
|
||||
t.Errorf("anonymous session = %+v", sess)
|
||||
}
|
||||
if rec = do(t, h, http.MethodGet, "/api/v1/items", ""); rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("items without session = %d, want 401", rec.Code)
|
||||
}
|
||||
|
||||
// Login redirect: PKCE + state + nonce present, redirect URI rebuilt
|
||||
// from the request host.
|
||||
txCookie, authURL := startLogin(t, h, "/api/auth/login?returnTo=/overview")
|
||||
q := authURL.Query()
|
||||
if q.Get("response_type") != "code" || q.Get("client_id") != idp.clientID {
|
||||
t.Errorf("authorize query = %v", q)
|
||||
}
|
||||
if q.Get("code_challenge") == "" || q.Get("code_challenge_method") != "S256" {
|
||||
t.Errorf("PKCE missing: %v", q)
|
||||
}
|
||||
if q.Get("state") == "" || q.Get("nonce") == "" {
|
||||
t.Errorf("state/nonce missing: %v", q)
|
||||
}
|
||||
if got := q.Get("redirect_uri"); got != "http://localhost:5173/api/auth/callback" {
|
||||
t.Errorf("redirect_uri = %q", got)
|
||||
}
|
||||
// The IdP remembers the nonce for the code it would mint here.
|
||||
idp.nonceForCode["stub-code"] = q.Get("nonce")
|
||||
|
||||
// Callback: state + tx cookie exchange for a session cookie.
|
||||
req := httptest.NewRequest(http.MethodGet,
|
||||
"/api/auth/callback?code=stub-code&state="+url.QueryEscape(q.Get("state")), nil)
|
||||
req.Host = "localhost:5173"
|
||||
req.AddCookie(txCookie)
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusFound {
|
||||
t.Fatalf("callback = %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if loc := rec.Header().Get("Location"); loc != "/overview" {
|
||||
t.Errorf("callback Location = %q, want /overview", loc)
|
||||
}
|
||||
var sessionCookie *http.Cookie
|
||||
for _, c := range rec.Result().Cookies() {
|
||||
if c.Name == auth.CookieSession {
|
||||
sessionCookie = c
|
||||
}
|
||||
}
|
||||
if sessionCookie == nil {
|
||||
t.Fatal("callback set no session cookie")
|
||||
}
|
||||
|
||||
// Signed in: session knows the user, /api/v1 opens up.
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/auth/session", nil)
|
||||
req.AddCookie(sessionCookie)
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("session = %d", rec.Code)
|
||||
}
|
||||
var after struct {
|
||||
Authenticated bool `json:"authenticated"`
|
||||
User struct {
|
||||
Sub string `json:"sub"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
Roles []string `json:"roles"`
|
||||
} `json:"user"`
|
||||
}
|
||||
decodeBody(t, rec, &after)
|
||||
if !after.Authenticated || after.User.Sub != "user-1" ||
|
||||
after.User.Name != "Test User" || after.User.Email != "test@example.com" {
|
||||
t.Errorf("session after login = %+v", after)
|
||||
}
|
||||
if len(after.User.Roles) != 1 || after.User.Roles[0] != "admin" {
|
||||
t.Errorf("roles = %v", after.User.Roles)
|
||||
}
|
||||
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/items", nil)
|
||||
req.AddCookie(sessionCookie)
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("items with session = %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// Logout clears the session; /api/v1 closes again.
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/auth/logout", nil)
|
||||
req.AddCookie(sessionCookie)
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("logout = %d", rec.Code)
|
||||
}
|
||||
var cleared *http.Cookie
|
||||
for _, c := range rec.Result().Cookies() {
|
||||
if c.Name == auth.CookieSession && c.MaxAge < 0 {
|
||||
cleared = c
|
||||
}
|
||||
}
|
||||
if cleared == nil {
|
||||
t.Fatal("logout did not expire the session cookie")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOIDCCallbackRejectsBadState(t *testing.T) {
|
||||
idp := newFakeIDP(t)
|
||||
h := newAuthTestServer(t, idp)
|
||||
|
||||
txCookie, _ := startLogin(t, h, "/api/auth/login")
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/auth/callback?code=c&state=wrong", nil)
|
||||
req.AddCookie(txCookie)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusFound || rec.Header().Get("Location") != "/?auth_error=state_mismatch" {
|
||||
t.Fatalf("callback with wrong state = %d %q", rec.Code, rec.Header().Get("Location"))
|
||||
}
|
||||
|
||||
// And without any tx cookie at all.
|
||||
rec = do(t, h, http.MethodGet, "/api/auth/callback?code=c&state=x", "")
|
||||
if rec.Code != http.StatusFound || rec.Header().Get("Location") != "/?auth_error=expired_login" {
|
||||
t.Fatalf("callback without tx cookie = %d %q", rec.Code, rec.Header().Get("Location"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestOIDCCallbackPassesIssuerErrorThrough(t *testing.T) {
|
||||
idp := newFakeIDP(t)
|
||||
h := newAuthTestServer(t, idp)
|
||||
|
||||
rec := do(t, h, http.MethodGet, "/api/auth/callback?error=access_denied", "")
|
||||
if rec.Code != http.StatusFound ||
|
||||
rec.Header().Get("Location") != "/?auth_error=access_denied" {
|
||||
t.Fatalf("callback issuer error = %d %q", rec.Code, rec.Header().Get("Location"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthRoutesAbsentInStandaloneMode(t *testing.T) {
|
||||
h := api.New(api.Deps{
|
||||
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||
Items: items.NewService(newFakeRepo()),
|
||||
DB: stubPinger{},
|
||||
AllowedOrigins: []string{"http://localhost:5173"},
|
||||
})
|
||||
if rec := do(t, h, http.MethodGet, "/api/auth/session", ""); rec.Code != http.StatusNotFound {
|
||||
t.Errorf("session without OIDC = %d, want 404", rec.Code)
|
||||
}
|
||||
if rec := do(t, h, http.MethodGet, "/api/v1/items", ""); rec.Code != http.StatusOK {
|
||||
t.Errorf("items without OIDC = %d, want 200 (standalone stays open)", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireAuthCoversUnknownV1Routes(t *testing.T) {
|
||||
idp := newFakeIDP(t)
|
||||
h := newAuthTestServer(t, idp)
|
||||
if rec := do(t, h, http.MethodGet, "/api/v1/nope", ""); rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("unknown /api/v1 route = %d, want 401 (not 404)", rec.Code)
|
||||
}
|
||||
if !strings.Contains(recBodyFor(t, h, "/api/v1/nope"), "unauthorized") {
|
||||
t.Error("401 body should carry the unauthorized code")
|
||||
}
|
||||
}
|
||||
|
||||
func TestForwardedHeadersBuildPreviewRedirectURI(t *testing.T) {
|
||||
idp := newFakeIDP(t)
|
||||
h := newAuthTestServer(t, idp)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/auth/login", nil)
|
||||
req.Host = "backend.internal:8080"
|
||||
req.Header.Set("X-Forwarded-Host", "worker-7.preview.k6n.net")
|
||||
req.Header.Set("X-Forwarded-Proto", "https")
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
authURL, err := url.Parse(rec.Header().Get("Location"))
|
||||
if err != nil {
|
||||
t.Fatalf("parse authorize url: %v", err)
|
||||
}
|
||||
if got := authURL.Query().Get("redirect_uri"); got != "https://worker-7.preview.k6n.net/api/auth/callback" {
|
||||
t.Errorf("redirect_uri = %q", got)
|
||||
}
|
||||
// Session cookies set behind https must carry Secure.
|
||||
var txCookie *http.Cookie
|
||||
for _, c := range rec.Result().Cookies() {
|
||||
if c.Name == auth.CookieLoginTx {
|
||||
txCookie = c
|
||||
}
|
||||
}
|
||||
if txCookie == nil || !txCookie.Secure {
|
||||
t.Fatal("tx cookie behind https must be Secure")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReturnToSanitization(t *testing.T) {
|
||||
idp := newFakeIDP(t)
|
||||
h := newAuthTestServer(t, idp)
|
||||
|
||||
// Open-redirect attempts fall back to "/" inside the tx cookie.
|
||||
for _, raw := range []string{"https://evil.example", "//evil.example", "\r\n/x", "\\evil"} {
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/auth/login?returnTo="+url.QueryEscape(raw), nil)
|
||||
h.ServeHTTP(rec, req)
|
||||
var txCookie *http.Cookie
|
||||
for _, c := range rec.Result().Cookies() {
|
||||
if c.Name == auth.CookieLoginTx {
|
||||
txCookie = c
|
||||
}
|
||||
}
|
||||
if txCookie == nil {
|
||||
t.Fatalf("returnTo=%q: no tx cookie", raw)
|
||||
}
|
||||
var tx auth.LoginTx
|
||||
if err := auth.NewSigner("test-signing-secret").Open(txCookie.Value, &tx); err != nil {
|
||||
t.Fatalf("open tx cookie: %v", err)
|
||||
}
|
||||
if tx.ReturnTo != "/" {
|
||||
t.Errorf("returnTo=%q sanitized to %q, want /", raw, tx.ReturnTo)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func recBodyFor(t *testing.T, h http.Handler, target string) string {
|
||||
t.Helper()
|
||||
return do(t, h, http.MethodGet, target, "").Body.String()
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"git.k6n.net/mats/fullstack-template/internal/items"
|
||||
)
|
||||
|
||||
// itemHandler adapts HTTP requests to the items service: parse input, call
|
||||
// the service, map errors to status codes. No business logic lives here.
|
||||
type itemHandler struct {
|
||||
svc *items.Service
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func (h *itemHandler) list(w http.ResponseWriter, r *http.Request) {
|
||||
f := items.Filter{
|
||||
Status: items.Status(r.URL.Query().Get("status")),
|
||||
Limit: queryInt(r, "limit", items.DefaultLimit),
|
||||
Offset: queryInt(r, "offset", 0),
|
||||
}
|
||||
result, err := h.svc.List(r.Context(), f)
|
||||
if err != nil {
|
||||
h.fail(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
func (h *itemHandler) create(w http.ResponseWriter, r *http.Request) {
|
||||
var in items.CreateInput
|
||||
if !decodeJSON(w, r, &in) {
|
||||
return
|
||||
}
|
||||
item, err := h.svc.Create(r.Context(), in)
|
||||
if err != nil {
|
||||
h.fail(w, err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Location", "/api/v1/items/"+strconv.FormatInt(item.ID, 10))
|
||||
writeJSON(w, http.StatusCreated, item)
|
||||
}
|
||||
|
||||
func (h *itemHandler) get(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := pathID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
item, err := h.svc.Get(r.Context(), id)
|
||||
if err != nil {
|
||||
h.fail(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, item)
|
||||
}
|
||||
|
||||
func (h *itemHandler) update(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := pathID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var in items.UpdateInput
|
||||
if !decodeJSON(w, r, &in) {
|
||||
return
|
||||
}
|
||||
item, err := h.svc.Update(r.Context(), id, in)
|
||||
if err != nil {
|
||||
h.fail(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, item)
|
||||
}
|
||||
|
||||
func (h *itemHandler) delete(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := pathID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := h.svc.Delete(r.Context(), id); err != nil {
|
||||
h.fail(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// pathID parses the {id} path segment; writes a 400 and returns false on
|
||||
// input that can't be an id.
|
||||
func pathID(w http.ResponseWriter, r *http.Request) (int64, bool) {
|
||||
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
writeError(w, http.StatusBadRequest, "invalid_id", "id must be a positive integer")
|
||||
return 0, false
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
|
||||
// queryInt reads an int query parameter, falling back when absent or junk.
|
||||
func queryInt(r *http.Request, key string, fallback int) int {
|
||||
v := r.URL.Query().Get(key)
|
||||
if v == "" {
|
||||
return fallback
|
||||
}
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil || n < 0 {
|
||||
return fallback
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// fail is the single translation point from items errors to the wire:
|
||||
// ErrNotFound → 404, *ValidationError → 400, anything else → 500 (logged).
|
||||
func (h *itemHandler) fail(w http.ResponseWriter, err error) {
|
||||
var vErr *items.ValidationError
|
||||
switch {
|
||||
case errors.Is(err, items.ErrNotFound):
|
||||
writeError(w, http.StatusNotFound, "not_found", "item not found")
|
||||
case errors.As(err, &vErr):
|
||||
writeError(w, http.StatusBadRequest, "validation_failed", vErr.Error())
|
||||
default:
|
||||
h.logger.Error("request failed", "err", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "an internal error occurred")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"runtime/debug"
|
||||
"time"
|
||||
)
|
||||
|
||||
// middleware wraps an http.Handler. Keep middleware transport-level;
|
||||
// business concerns belong in services.
|
||||
type middleware func(http.Handler) http.Handler
|
||||
|
||||
// chain composes middlewares so chain(h, a, b) == a(b(h)): the first
|
||||
// argument runs outermost.
|
||||
func chain(h http.Handler, mws ...middleware) http.Handler {
|
||||
for i := len(mws) - 1; i >= 0; i-- {
|
||||
h = mws[i](h)
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// logging records one line per request after it completes.
|
||||
func logging(logger *slog.Logger) middleware {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
|
||||
next.ServeHTTP(rec, r)
|
||||
logger.Info("request",
|
||||
"method", r.Method,
|
||||
"path", r.URL.Path,
|
||||
"status", rec.status,
|
||||
"duration_ms", time.Since(start).Milliseconds(),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// recoverPanics turns panics into 500s instead of dropped connections.
|
||||
func recoverPanics(logger *slog.Logger) middleware {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
defer func() {
|
||||
if v := recover(); v != nil {
|
||||
logger.Error("panic recovered", "panic", v, "stack", string(debug.Stack()))
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "an internal error occurred")
|
||||
}
|
||||
}()
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// cors lets the configured origins call the API from a browser (e.g. the
|
||||
// Vite dev server on :5173). A "*" entry allows every origin.
|
||||
func cors(allowed []string) middleware {
|
||||
set := make(map[string]struct{}, len(allowed))
|
||||
allowAll := false
|
||||
for _, o := range allowed {
|
||||
if o == "*" {
|
||||
allowAll = true
|
||||
}
|
||||
set[o] = struct{}{}
|
||||
}
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
origin := r.Header.Get("Origin")
|
||||
if _, ok := set[origin]; origin != "" && (allowAll || ok) {
|
||||
h := w.Header()
|
||||
h.Set("Access-Control-Allow-Origin", origin)
|
||||
h.Add("Vary", "Origin")
|
||||
h.Set("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS")
|
||||
h.Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
||||
h.Set("Access-Control-Max-Age", "600")
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// timeout bounds each request's handler time. The body is the raw timeout
|
||||
// response — http.TimeoutHandler does not set a content type for it.
|
||||
func timeout(d time.Duration) middleware {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.TimeoutHandler(next, d,
|
||||
`{"error":{"code":"timeout","message":"request timed out"}}`)
|
||||
}
|
||||
}
|
||||
|
||||
// statusRecorder captures the response status for logging.
|
||||
type statusRecorder struct {
|
||||
http.ResponseWriter
|
||||
status int
|
||||
}
|
||||
|
||||
func (r *statusRecorder) WriteHeader(code int) {
|
||||
r.status = code
|
||||
r.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
// Unwrap lets http.ResponseController reach the underlying writer.
|
||||
func (r *statusRecorder) Unwrap() http.ResponseWriter { return r.ResponseWriter }
|
||||
@@ -0,0 +1,53 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// Error responses always use this envelope:
|
||||
//
|
||||
// {"error": {"code": "...", "message": "..."}}
|
||||
//
|
||||
// Known codes: invalid_json, validation_failed, not_found, invalid_id,
|
||||
// method_not_allowed, internal_error, timeout, unavailable. Add new codes
|
||||
// here when needed.
|
||||
type errorResponse struct {
|
||||
Error errorDetail `json:"error"`
|
||||
}
|
||||
|
||||
type errorDetail struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// maxBodySize caps JSON request bodies at 1 MiB.
|
||||
const maxBodySize = 1 << 20
|
||||
|
||||
// writeJSON sets the content type and encodes v.
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
// The status line is already sent, so an encode failure can only be
|
||||
// logged — and only by wrapping the ResponseWriter. Not worth it here.
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
// writeError emits the error envelope with the given status and code.
|
||||
func writeError(w http.ResponseWriter, status int, code, message string) {
|
||||
writeJSON(w, status, errorResponse{Error: errorDetail{Code: code, Message: message}})
|
||||
}
|
||||
|
||||
// decodeJSON strictly decodes the request body into dst: unknown fields and
|
||||
// bodies over maxBodySize are rejected. It writes the 400 response itself
|
||||
// and returns false, telling the handler to stop.
|
||||
func decodeJSON(w http.ResponseWriter, r *http.Request, dst any) bool {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxBodySize)
|
||||
dec := json.NewDecoder(r.Body)
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(dst); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_json", "request body is not valid JSON: "+err.Error())
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
// Package api wires HTTP routing, middleware, and JSON conventions on top
|
||||
// of the stdlib ServeMux (Go 1.22+ method patterns and r.PathValue).
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.k6n.net/mats/fullstack-template/internal/items"
|
||||
)
|
||||
|
||||
// Pinger abstracts the database health check so tests can stub it.
|
||||
type Pinger interface {
|
||||
PingContext(ctx context.Context) error
|
||||
}
|
||||
|
||||
// Deps are the server's runtime collaborators, wired in cmd/api.
|
||||
type Deps struct {
|
||||
Logger *slog.Logger
|
||||
Items *items.Service
|
||||
DB Pinger
|
||||
AllowedOrigins []string
|
||||
RequestTimeout time.Duration // zero means the 30s default
|
||||
Auth *AuthDeps // nil disables OIDC: /api/auth answers 404, /api/v1 stays open
|
||||
}
|
||||
|
||||
const defaultRequestTimeout = 30 * time.Second
|
||||
|
||||
// New builds the full handler: middleware chain around the route table.
|
||||
// Middleware order (outermost first): logging → recover → cors → timeout
|
||||
// → (requireAuth, only when OIDC is configured).
|
||||
func New(deps Deps) http.Handler {
|
||||
if deps.RequestTimeout <= 0 {
|
||||
deps.RequestTimeout = defaultRequestTimeout
|
||||
}
|
||||
|
||||
var authH *authHandler
|
||||
if deps.Auth != nil {
|
||||
if deps.Auth.SessionTTL <= 0 {
|
||||
deps.Auth.SessionTTL = defaultSessionTTL
|
||||
}
|
||||
authH = &authHandler{
|
||||
client: deps.Auth.Client,
|
||||
signer: deps.Auth.Signer,
|
||||
sessionTTL: deps.Auth.SessionTTL,
|
||||
logger: deps.Logger,
|
||||
}
|
||||
}
|
||||
|
||||
s := &server{
|
||||
logger: deps.Logger,
|
||||
db: deps.DB,
|
||||
items: &itemHandler{svc: deps.Items, logger: deps.Logger},
|
||||
auth: authH,
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// Liveness is "the process is up"; readiness is "it can serve real
|
||||
// traffic", here meaning the database answers.
|
||||
mux.HandleFunc("GET /healthz", s.health)
|
||||
mux.HandleFunc("GET /readyz", s.readiness)
|
||||
|
||||
if s.auth != nil {
|
||||
mux.HandleFunc("GET /api/auth/session", s.auth.session)
|
||||
mux.HandleFunc("GET /api/auth/login", s.auth.login)
|
||||
mux.HandleFunc("GET /api/auth/callback", s.auth.callback)
|
||||
mux.HandleFunc("POST /api/auth/logout", s.auth.logout)
|
||||
}
|
||||
|
||||
// Reference CRUD resource. Method-specific patterns make the mux
|
||||
// answer 405 with an Allow header for free; read segments with
|
||||
// r.PathValue("id").
|
||||
mux.HandleFunc("GET /api/v1/items", s.items.list)
|
||||
mux.HandleFunc("POST /api/v1/items", s.items.create)
|
||||
mux.HandleFunc("GET /api/v1/items/{id}", s.items.get)
|
||||
mux.HandleFunc("PATCH /api/v1/items/{id}", s.items.update)
|
||||
mux.HandleFunc("DELETE /api/v1/items/{id}", s.items.delete)
|
||||
|
||||
// The wrapper keeps the mux's automatic 405 + Allow responses for
|
||||
// wrong-method requests and renders all unmatched routes as JSON 404s
|
||||
// (the mux default is plain text).
|
||||
mws := []middleware{
|
||||
logging(deps.Logger),
|
||||
recoverPanics(deps.Logger),
|
||||
cors(deps.AllowedOrigins),
|
||||
timeout(deps.RequestTimeout),
|
||||
}
|
||||
if s.auth != nil {
|
||||
mws = append(mws, requireAuth(deps.Auth.Signer))
|
||||
}
|
||||
return chain(jsonNotFounds(mux), mws...)
|
||||
}
|
||||
|
||||
type server struct {
|
||||
logger *slog.Logger
|
||||
db Pinger
|
||||
items *itemHandler
|
||||
auth *authHandler
|
||||
}
|
||||
|
||||
// jsonNotFounds delegates to mux and turns its default plain-text responses
|
||||
// for unmatched requests into the JSON error envelope. A registered "/" or
|
||||
// catch-all route would swallow the mux's automatic 405s, so this checks
|
||||
// mux.Handler instead: an empty pattern means nothing matched, and probing
|
||||
// the registered methods distinguishes "wrong method" (405 + Allow) from
|
||||
// "unknown route" (404). The probe runs only on the miss path.
|
||||
func jsonNotFounds(mux *http.ServeMux) http.Handler {
|
||||
methods := []string{
|
||||
http.MethodGet, http.MethodPost, http.MethodPut,
|
||||
http.MethodPatch, http.MethodDelete,
|
||||
}
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if _, pattern := mux.Handler(r); pattern == "" {
|
||||
var allowed []string
|
||||
for _, m := range methods {
|
||||
probe := r.Clone(r.Context())
|
||||
probe.Method = m
|
||||
if _, pattern := mux.Handler(probe); pattern != "" {
|
||||
allowed = append(allowed, m)
|
||||
}
|
||||
}
|
||||
if len(allowed) > 0 {
|
||||
w.Header().Set("Allow", strings.Join(allowed, ", "))
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed",
|
||||
r.Method+" is not allowed for this route")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusNotFound, "not_found", "route not found")
|
||||
return
|
||||
}
|
||||
mux.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *server) health(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (s *server) readiness(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
|
||||
defer cancel()
|
||||
if err := s.db.PingContext(ctx); err != nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "unavailable", "database is not reachable")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ready"})
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
package api_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.k6n.net/mats/fullstack-template/internal/api"
|
||||
"git.k6n.net/mats/fullstack-template/internal/items"
|
||||
)
|
||||
|
||||
// These tests run the full HTTP stack (mux patterns, middleware, JSON
|
||||
// conventions) against the real items service over an in-memory repository.
|
||||
|
||||
// fakeRepo is the same in-memory fake as internal/items/service_test.go,
|
||||
// duplicated on purpose: test fakes stay package-local.
|
||||
type fakeRepo struct {
|
||||
mu sync.Mutex
|
||||
nextID int64
|
||||
byID map[int64]items.Item
|
||||
}
|
||||
|
||||
func newFakeRepo() *fakeRepo {
|
||||
return &fakeRepo{nextID: 1, byID: map[int64]items.Item{}}
|
||||
}
|
||||
|
||||
func (f *fakeRepo) List(_ context.Context, fl items.Filter) ([]items.Item, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
ids := make([]int64, 0, len(f.byID))
|
||||
for id := range f.byID {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
|
||||
out := []items.Item{}
|
||||
for _, id := range ids {
|
||||
item := f.byID[id]
|
||||
if fl.Status != "" && item.Status != fl.Status {
|
||||
continue
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
if fl.Offset < len(out) {
|
||||
out = out[fl.Offset:]
|
||||
} else {
|
||||
out = []items.Item{}
|
||||
}
|
||||
if fl.Limit < len(out) {
|
||||
out = out[:fl.Limit]
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (f *fakeRepo) Get(_ context.Context, id int64) (items.Item, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
item, ok := f.byID[id]
|
||||
if !ok {
|
||||
return items.Item{}, items.ErrNotFound
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (f *fakeRepo) Create(_ context.Context, item *items.Item) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
item.ID = f.nextID
|
||||
f.nextID++
|
||||
now := time.Now()
|
||||
item.CreatedAt, item.UpdatedAt = now, now
|
||||
f.byID[item.ID] = *item
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeRepo) Update(_ context.Context, item *items.Item) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if _, ok := f.byID[item.ID]; !ok {
|
||||
return items.ErrNotFound
|
||||
}
|
||||
item.UpdatedAt = time.Now()
|
||||
f.byID[item.ID] = *item
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeRepo) Delete(_ context.Context, id int64) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if _, ok := f.byID[id]; !ok {
|
||||
return items.ErrNotFound
|
||||
}
|
||||
delete(f.byID, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
// stubPinger fakes the database for /readyz.
|
||||
type stubPinger struct{ err error }
|
||||
|
||||
func (p stubPinger) PingContext(context.Context) error { return p.err }
|
||||
|
||||
func newTestServer(t *testing.T) http.Handler {
|
||||
t.Helper()
|
||||
return api.New(api.Deps{
|
||||
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||
Items: items.NewService(newFakeRepo()),
|
||||
DB: stubPinger{},
|
||||
AllowedOrigins: []string{"http://localhost:5173"},
|
||||
})
|
||||
}
|
||||
|
||||
func do(t *testing.T, h http.Handler, method, target, body string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
var rdr io.Reader
|
||||
if body != "" {
|
||||
rdr = strings.NewReader(body)
|
||||
}
|
||||
req := httptest.NewRequest(method, target, rdr)
|
||||
if body != "" {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
func decodeBody(t *testing.T, rec *httptest.ResponseRecorder, dst any) {
|
||||
t.Helper()
|
||||
if err := json.NewDecoder(rec.Body).Decode(dst); err != nil {
|
||||
t.Fatalf("decode response body %q: %v", rec.Body.String(), err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthAndReadiness(t *testing.T) {
|
||||
h := newTestServer(t)
|
||||
|
||||
if rec := do(t, h, http.MethodGet, "/healthz", ""); rec.Code != http.StatusOK {
|
||||
t.Errorf("healthz = %d, want 200", rec.Code)
|
||||
}
|
||||
if rec := do(t, h, http.MethodGet, "/readyz", ""); rec.Code != http.StatusOK {
|
||||
t.Errorf("readyz = %d, want 200", rec.Code)
|
||||
}
|
||||
|
||||
sick := api.New(api.Deps{
|
||||
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||
Items: items.NewService(newFakeRepo()),
|
||||
DB: stubPinger{err: errors.New("down")},
|
||||
})
|
||||
if rec := do(t, sick, http.MethodGet, "/readyz", ""); rec.Code != http.StatusServiceUnavailable {
|
||||
t.Errorf("readyz with dead db = %d, want 503", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestItemCRUDFlow(t *testing.T) {
|
||||
h := newTestServer(t)
|
||||
|
||||
// Create.
|
||||
rec := do(t, h, http.MethodPost, "/api/v1/items", `{"title":"First","description":"hello"}`)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("create = %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var created items.Item
|
||||
decodeBody(t, rec, &created)
|
||||
if created.ID != 1 || created.Title != "First" || created.Status != items.StatusActive {
|
||||
t.Errorf("created = %+v", created)
|
||||
}
|
||||
if loc := rec.Header().Get("Location"); loc != "/api/v1/items/1" {
|
||||
t.Errorf("Location = %q, want /api/v1/items/1", loc)
|
||||
}
|
||||
|
||||
// Get by id (PathValue routing).
|
||||
rec = do(t, h, http.MethodGet, "/api/v1/items/1", "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("get = %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var got items.Item
|
||||
decodeBody(t, rec, &got)
|
||||
if got.Title != "First" {
|
||||
t.Errorf("get title = %q", got.Title)
|
||||
}
|
||||
|
||||
// Patch one field.
|
||||
rec = do(t, h, http.MethodPatch, "/api/v1/items/1", `{"status":"archived"}`)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("patch = %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var patched items.Item
|
||||
decodeBody(t, rec, &patched)
|
||||
if patched.Status != items.StatusArchived || patched.Title != "First" {
|
||||
t.Errorf("patched = %+v", patched)
|
||||
}
|
||||
|
||||
// List.
|
||||
rec = do(t, h, http.MethodGet, "/api/v1/items", "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("list = %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var listed []items.Item
|
||||
decodeBody(t, rec, &listed)
|
||||
if len(listed) != 1 {
|
||||
t.Fatalf("list len = %d, want 1", len(listed))
|
||||
}
|
||||
|
||||
// Delete, then confirm it's gone.
|
||||
if rec := do(t, h, http.MethodDelete, "/api/v1/items/1", ""); rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("delete = %d, want 204", rec.Code)
|
||||
}
|
||||
if rec := do(t, h, http.MethodGet, "/api/v1/items/1", ""); rec.Code != http.StatusNotFound {
|
||||
t.Errorf("get after delete = %d, want 404", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorResponses(t *testing.T) {
|
||||
h := newTestServer(t)
|
||||
do(t, h, http.MethodPost, "/api/v1/items", `{"title":"seed"}`)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
method string
|
||||
target string
|
||||
body string
|
||||
wantStatus int
|
||||
wantCode string
|
||||
}{
|
||||
{"invalid path id", "GET", "/api/v1/items/abc", "", 400, "invalid_id"},
|
||||
{"unknown item", "GET", "/api/v1/items/999", "", 404, "not_found"},
|
||||
{"broken json", "POST", "/api/v1/items", `{"title":`, 400, "invalid_json"},
|
||||
{"unknown field", "POST", "/api/v1/items", `{"title":"x","wat":1}`, 400, "invalid_json"},
|
||||
{"validation", "POST", "/api/v1/items", `{"title":""}`, 400, "validation_failed"},
|
||||
{"unknown route", "GET", "/api/v1/nope", "", 404, "not_found"},
|
||||
{"empty patch", "PATCH", "/api/v1/items/1", `{}`, 400, "validation_failed"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
rec := do(t, h, tt.method, tt.target, tt.body)
|
||||
if rec.Code != tt.wantStatus {
|
||||
t.Fatalf("status = %d, want %d (body %s)", rec.Code, tt.wantStatus, rec.Body.String())
|
||||
}
|
||||
var res struct {
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
} `json:"error"`
|
||||
}
|
||||
decodeBody(t, rec, &res)
|
||||
if res.Error.Code != tt.wantCode {
|
||||
t.Errorf("code = %q, want %q", res.Error.Code, tt.wantCode)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMethodNotAllowed(t *testing.T) {
|
||||
h := newTestServer(t)
|
||||
rec := do(t, h, http.MethodPut, "/api/v1/items/1", `{}`)
|
||||
if rec.Code != http.StatusMethodNotAllowed {
|
||||
t.Fatalf("put = %d, want 405 (ServeMux method patterns)", rec.Code)
|
||||
}
|
||||
if allow := rec.Header().Get("Allow"); !strings.Contains(allow, "PATCH") {
|
||||
t.Errorf("Allow = %q, want it to mention PATCH", allow)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCORSPreflight(t *testing.T) {
|
||||
h := newTestServer(t)
|
||||
|
||||
req := httptest.NewRequest(http.MethodOptions, "/api/v1/items", nil)
|
||||
req.Header.Set("Origin", "http://localhost:5173")
|
||||
req.Header.Set("Access-Control-Request-Method", http.MethodPost)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("preflight = %d, want 204", rec.Code)
|
||||
}
|
||||
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "http://localhost:5173" {
|
||||
t.Errorf("Allow-Origin = %q", got)
|
||||
}
|
||||
|
||||
// Unknown origins get no CORS headers and fall through to the mux.
|
||||
req = httptest.NewRequest(http.MethodOptions, "/api/v1/items", nil)
|
||||
req.Header.Set("Origin", "http://evil.example")
|
||||
req.Header.Set("Access-Control-Request-Method", http.MethodPost)
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "" {
|
||||
t.Errorf("Allow-Origin for unknown origin = %q, want none", got)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user