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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Cookie names. The login-tx cookie is scoped to /api/auth so it is only
|
||||
// sent to the endpoints that need it.
|
||||
const (
|
||||
CookieSession = "fs_session"
|
||||
CookieLoginTx = "fs_auth_tx"
|
||||
)
|
||||
|
||||
// Session is the signed-in identity kept in the session cookie. It is a
|
||||
// self-contained, HMAC-protected claim set — not a bearer token: it carries
|
||||
// no issuer credentials and only opens this API.
|
||||
type Session struct {
|
||||
Sub string
|
||||
Name string
|
||||
Email string
|
||||
Roles []string
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
// Valid reports whether the session is still within its TTL.
|
||||
func (s Session) Valid() bool { return time.Now().Before(s.ExpiresAt) }
|
||||
|
||||
// ErrTampered is returned when a cookie value fails its HMAC check or is
|
||||
// malformed — treat it exactly like a missing cookie.
|
||||
var ErrTampered = errors.New("auth cookie failed integrity check")
|
||||
|
||||
// Signer seals values into "<base64url(json)>.<base64url(hmac-sha256)>"
|
||||
// cookie payloads and opens them back. Construction is cheap; keep one
|
||||
// per server and share it.
|
||||
type Signer struct{ key []byte }
|
||||
|
||||
// NewSigner returns a Signer keyed by secret.
|
||||
func NewSigner(secret string) *Signer { return &Signer{key: []byte(secret)} }
|
||||
|
||||
// Seal encodes v into a signed cookie value.
|
||||
func (s *Signer) Seal(v any) (string, error) {
|
||||
payload, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("marshal cookie payload: %w", err)
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(payload) + "." + s.mac(payload), nil
|
||||
}
|
||||
|
||||
// Open verifies a sealed value and decodes it into v.
|
||||
func (s *Signer) Open(sealed string, v any) error {
|
||||
value, mac, ok := strings.Cut(sealed, ".")
|
||||
if !ok {
|
||||
return ErrTampered
|
||||
}
|
||||
payload, err := base64.RawURLEncoding.DecodeString(value)
|
||||
if err != nil {
|
||||
return ErrTampered
|
||||
}
|
||||
sig, err := base64.RawURLEncoding.DecodeString(mac)
|
||||
if err != nil {
|
||||
return ErrTampered
|
||||
}
|
||||
if !hmac.Equal(sig, s.macBytes(payload)) {
|
||||
return ErrTampered
|
||||
}
|
||||
if err := json.Unmarshal(payload, v); err != nil {
|
||||
return ErrTampered
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Signer) mac(payload []byte) string {
|
||||
return base64.RawURLEncoding.EncodeToString(s.macBytes(payload))
|
||||
}
|
||||
|
||||
func (s *Signer) macBytes(payload []byte) []byte {
|
||||
h := hmac.New(sha256.New, s.key)
|
||||
h.Write(payload)
|
||||
return h.Sum(nil)
|
||||
}
|
||||
|
||||
// NewSessionCookie returns the Set-Cookie for a session valid for ttl.
|
||||
func (s *Signer) NewSessionCookie(sess Session, ttl time.Duration, secure bool) (*http.Cookie, error) {
|
||||
sess.ExpiresAt = time.Now().Add(ttl)
|
||||
value, err := s.Seal(sess)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &http.Cookie{
|
||||
Name: CookieSession,
|
||||
Value: value,
|
||||
Path: "/",
|
||||
MaxAge: int(ttl.Seconds()),
|
||||
HttpOnly: true,
|
||||
Secure: secure,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// NewLoginTxCookie returns the short-lived Set-Cookie carrying tx between
|
||||
// the login redirect and the callback.
|
||||
func (s *Signer) NewLoginTxCookie(tx LoginTx, secure bool) (*http.Cookie, error) {
|
||||
value, err := s.Seal(tx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &http.Cookie{
|
||||
Name: CookieLoginTx,
|
||||
Value: value,
|
||||
Path: "/api/auth",
|
||||
MaxAge: int(txTTL.Seconds()),
|
||||
HttpOnly: true,
|
||||
Secure: secure,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Expired returns a Set-Cookie that deletes the named cookie.
|
||||
func Expired(name string, secure bool) *http.Cookie {
|
||||
return &http.Cookie{
|
||||
Name: name,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
MaxAge: -1,
|
||||
HttpOnly: true,
|
||||
Secure: secure,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSignerSealOpen(t *testing.T) {
|
||||
s := NewSigner("test-secret")
|
||||
tx, err := NewLoginTx("/some/path")
|
||||
if err != nil {
|
||||
t.Fatalf("NewLoginTx: %v", err)
|
||||
}
|
||||
|
||||
sealed, err := s.Seal(tx)
|
||||
if err != nil {
|
||||
t.Fatalf("Seal: %v", err)
|
||||
}
|
||||
var opened LoginTx
|
||||
if err := s.Open(sealed, &opened); err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
// Compare fields: time.Time equality also compares the monotonic clock,
|
||||
// which the JSON roundtrip drops.
|
||||
if opened.State != tx.State || opened.Nonce != tx.Nonce ||
|
||||
opened.Verifier != tx.Verifier || opened.ReturnTo != tx.ReturnTo ||
|
||||
!opened.ExpiresAt.Equal(tx.ExpiresAt) {
|
||||
t.Errorf("opened = %+v, want %+v", opened, tx)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignerRejectsTampering(t *testing.T) {
|
||||
s := NewSigner("test-secret")
|
||||
sess := Session{Sub: "user-1", ExpiresAt: time.Now().Add(time.Hour)}
|
||||
sealed, err := s.Seal(sess)
|
||||
if err != nil {
|
||||
t.Fatalf("Seal: %v", err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
value func(string) string
|
||||
reason string
|
||||
}{
|
||||
{"flipped payload", func(v string) string {
|
||||
return flipLastChar(strings.Split(v, ".")[0]) + "." + strings.Split(v, ".")[1]
|
||||
}, "forged payload"},
|
||||
{"flipped mac", func(v string) string {
|
||||
return strings.Split(v, ".")[0] + "." + flipLastChar(strings.Split(v, ".")[1])
|
||||
}, "forged mac"},
|
||||
{"wrong key", func(v string) string {
|
||||
other, _ := NewSigner("other-secret").Seal(sess)
|
||||
return other
|
||||
}, "signed with a different key"},
|
||||
{"garbage", func(string) string { return "not-a-sealed-value" }, "garbage"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var out Session
|
||||
if err := s.Open(tt.value(sealed), &out); err == nil {
|
||||
t.Errorf("Open accepted %s", tt.reason)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionAndTxCookies(t *testing.T) {
|
||||
s := NewSigner("test-secret")
|
||||
|
||||
sessCookie, err := s.NewSessionCookie(Session{Sub: "u1"}, 2*time.Hour, true)
|
||||
if err != nil {
|
||||
t.Fatalf("NewSessionCookie: %v", err)
|
||||
}
|
||||
if sessCookie.Name != CookieSession || sessCookie.Path != "/" {
|
||||
t.Errorf("session cookie = %+v", sessCookie)
|
||||
}
|
||||
if !sessCookie.HttpOnly || !sessCookie.Secure || sessCookie.SameSite != http.SameSiteLaxMode {
|
||||
t.Errorf("session cookie attrs = %+v", sessCookie)
|
||||
}
|
||||
var sess Session
|
||||
if err := s.Open(sessCookie.Value, &sess); err != nil {
|
||||
t.Fatalf("Open session: %v", err)
|
||||
}
|
||||
if !sess.Valid() {
|
||||
t.Error("session with future ExpiresAt should be valid")
|
||||
}
|
||||
sess.ExpiresAt = time.Now().Add(-time.Minute)
|
||||
if sess.Valid() {
|
||||
t.Error("expired session should be invalid")
|
||||
}
|
||||
|
||||
tx, err := NewLoginTx("/")
|
||||
if err != nil {
|
||||
t.Fatalf("NewLoginTx: %v", err)
|
||||
}
|
||||
if !tx.Valid() {
|
||||
t.Error("fresh login tx should be valid")
|
||||
}
|
||||
tx.ExpiresAt = time.Now().Add(-time.Second)
|
||||
if tx.Valid() {
|
||||
t.Error("expired login tx should be invalid")
|
||||
}
|
||||
|
||||
tx, _ = NewLoginTx("/")
|
||||
txCookie, err := s.NewLoginTxCookie(tx, false)
|
||||
if err != nil {
|
||||
t.Fatalf("NewLoginTxCookie: %v", err)
|
||||
}
|
||||
if txCookie.Name != CookieLoginTx || txCookie.Path != "/api/auth" {
|
||||
t.Errorf("tx cookie = %+v", txCookie)
|
||||
}
|
||||
if !txCookie.HttpOnly || txCookie.Secure {
|
||||
t.Errorf("tx cookie attrs = %+v", txCookie)
|
||||
}
|
||||
|
||||
expired := Expired(CookieSession, true)
|
||||
if expired.Name != CookieSession || expired.MaxAge != -1 {
|
||||
t.Errorf("Expired cookie = %+v", expired)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewLoginTxUsesDistinctSecrets(t *testing.T) {
|
||||
a, _ := NewLoginTx("/")
|
||||
b, _ := NewLoginTx("/")
|
||||
if a.State == b.State || a.Nonce == b.Nonce || a.Verifier == b.Verifier {
|
||||
t.Error("state/nonce/verifier must be fresh per login")
|
||||
}
|
||||
}
|
||||
|
||||
// flipLastChar swaps the last character of a base64url value so any
|
||||
// signature or payload check must fail.
|
||||
func flipLastChar(v string) string {
|
||||
last := "A"
|
||||
if strings.HasSuffix(v, last) {
|
||||
last = "B"
|
||||
}
|
||||
return v[:len(v)-1] + last
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
// Package auth implements the browser login flow against an OpenID Connect
|
||||
// issuer: authorization code + PKCE (S256), discovery-driven endpoints, and
|
||||
// RS256 ID token verification via JWKS. The signed-in identity is kept in an
|
||||
// HMAC-protected session cookie (cookie.go) — tokens never reach the browser.
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/coreos/go-oidc/v3/oidc"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
// txTTL bounds the login transaction: the gap between redirecting to the
|
||||
// issuer and the callback that completes it.
|
||||
const txTTL = 10 * time.Minute
|
||||
|
||||
// Config identifies this app to the issuer. Scopes defaults to
|
||||
// ["openid","profile","email"] when empty.
|
||||
type Config struct {
|
||||
Issuer string
|
||||
ClientID string
|
||||
ClientSecret string
|
||||
Scopes []string
|
||||
}
|
||||
|
||||
// Client is the configured OIDC relying party. Discovery happens once in
|
||||
// New; the client is immutable and safe for concurrent use.
|
||||
type Client struct {
|
||||
oauth oauth2.Config
|
||||
verifier *oidc.IDTokenVerifier
|
||||
}
|
||||
|
||||
// New discovers the issuer's endpoints and returns a ready client.
|
||||
func New(ctx context.Context, cfg Config) (*Client, error) {
|
||||
provider, err := oidc.NewProvider(ctx, cfg.Issuer)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("discover issuer %s: %w", cfg.Issuer, err)
|
||||
}
|
||||
if len(cfg.Scopes) == 0 {
|
||||
cfg.Scopes = []string{"openid", "profile", "email"}
|
||||
}
|
||||
return &Client{
|
||||
oauth: oauth2.Config{
|
||||
ClientID: cfg.ClientID,
|
||||
ClientSecret: cfg.ClientSecret,
|
||||
Endpoint: provider.Endpoint(),
|
||||
Scopes: cfg.Scopes,
|
||||
},
|
||||
verifier: provider.Verifier(&oidc.Config{ClientID: cfg.ClientID}),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// User is the verified identity established by a completed login.
|
||||
type User struct {
|
||||
Sub string
|
||||
Name string
|
||||
Email string
|
||||
Roles []string
|
||||
}
|
||||
|
||||
// LoginTx is the per-login state kept in a short-lived cookie between the
|
||||
// redirect to the issuer and the callback: state and nonce guard against
|
||||
// CSRF and replay, the verifier carries PKCE, ReturnTo is where the browser
|
||||
// lands afterwards.
|
||||
type LoginTx struct {
|
||||
State string
|
||||
Nonce string
|
||||
Verifier string
|
||||
ReturnTo string
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
// NewLoginTx creates a login transaction with fresh state, nonce, and PKCE
|
||||
// verifier for the given post-login path.
|
||||
func NewLoginTx(returnTo string) (LoginTx, error) {
|
||||
state, err := randomToken(16)
|
||||
if err != nil {
|
||||
return LoginTx{}, err
|
||||
}
|
||||
nonce, err := randomToken(16)
|
||||
if err != nil {
|
||||
return LoginTx{}, err
|
||||
}
|
||||
verifier, err := randomToken(48)
|
||||
if err != nil {
|
||||
return LoginTx{}, err
|
||||
}
|
||||
return LoginTx{
|
||||
State: state,
|
||||
Nonce: nonce,
|
||||
Verifier: verifier,
|
||||
ReturnTo: returnTo,
|
||||
ExpiresAt: time.Now().Add(txTTL),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Valid reports whether the transaction is still within its TTL.
|
||||
func (tx LoginTx) Valid() bool { return time.Now().Before(tx.ExpiresAt) }
|
||||
|
||||
// AuthCodeURL builds the issuer's authorization URL for tx — PKCE challenge
|
||||
// and nonce included. redirectURI is the callback URL on the origin the
|
||||
// browser came from (it must be registered with the issuer).
|
||||
func (c *Client) AuthCodeURL(tx LoginTx, redirectURI string) string {
|
||||
return c.oauth.AuthCodeURL(tx.State,
|
||||
oauth2.SetAuthURLParam("redirect_uri", redirectURI),
|
||||
oauth2.SetAuthURLParam("nonce", tx.Nonce),
|
||||
oauth2.S256ChallengeOption(tx.Verifier),
|
||||
)
|
||||
}
|
||||
|
||||
// CompleteCode exchanges the authorization code (PKCE verifier from tx) and
|
||||
// verifies the returned ID token — signature via JWKS, plus iss/aud/exp and
|
||||
// the nonce — returning the signed-in user.
|
||||
func (c *Client) CompleteCode(ctx context.Context, code, redirectURI string, tx LoginTx) (User, error) {
|
||||
tok, err := c.oauth.Exchange(ctx, code,
|
||||
oauth2.SetAuthURLParam("redirect_uri", redirectURI),
|
||||
oauth2.VerifierOption(tx.Verifier),
|
||||
)
|
||||
if err != nil {
|
||||
return User{}, fmt.Errorf("exchange authorization code: %w", err)
|
||||
}
|
||||
rawIDToken, ok := tok.Extra("id_token").(string)
|
||||
if !ok || rawIDToken == "" {
|
||||
return User{}, errors.New("token response carries no id_token")
|
||||
}
|
||||
idToken, err := c.verifier.Verify(ctx, rawIDToken)
|
||||
if err != nil {
|
||||
return User{}, fmt.Errorf("verify id_token: %w", err)
|
||||
}
|
||||
if idToken.Nonce != tx.Nonce {
|
||||
return User{}, errors.New("id_token nonce does not match the login transaction")
|
||||
}
|
||||
var claims struct {
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
Roles []string `json:"roles"`
|
||||
}
|
||||
if err := idToken.Claims(&claims); err != nil {
|
||||
return User{}, fmt.Errorf("decode id_token claims: %w", err)
|
||||
}
|
||||
return User{Sub: idToken.Subject, Name: claims.Name, Email: claims.Email, Roles: claims.Roles}, nil
|
||||
}
|
||||
|
||||
// randomToken returns a URL-safe random string with n bytes of entropy.
|
||||
func randomToken(n int) (string, error) {
|
||||
b := make([]byte, n)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", fmt.Errorf("read randomness: %w", err)
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(b), nil
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
// Package config loads runtime configuration from the environment.
|
||||
//
|
||||
// Every setting has a safe local default — the database is a local SQLite
|
||||
// file, so the server runs with `make migrate-up && make run` and no .env
|
||||
// file, no containers. Point DATABASE_URL at Postgres for the real thing.
|
||||
// This is the only package that reads os.Getenv — add new settings here
|
||||
// and to .env.example, never at the point of use.
|
||||
package config
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Config is the validated runtime configuration.
|
||||
type Config struct {
|
||||
// Addr is the HTTP listen address.
|
||||
Addr string
|
||||
// DatabaseURL selects the database: "sqlite:FILE" for a local file
|
||||
// (dev default, pure-Go driver) or a "postgres://…" DSN.
|
||||
DatabaseURL string
|
||||
// AllowedOrigins lists CORS origins permitted to call the API from a
|
||||
// browser (the Vite dev server on :5173 by default). "*" allows all.
|
||||
AllowedOrigins []string
|
||||
// LogLevel and LogFormat configure the slog handler.
|
||||
LogLevel slog.Level
|
||||
LogFormat string // "json" or "text"
|
||||
// DebugQueries enables Bun query logging (bundebug).
|
||||
DebugQueries bool
|
||||
|
||||
// OIDCIssuer is the OpenID Connect issuer; endpoints are discovered,
|
||||
// never hardcoded. Auth is enabled when OIDCClientID is non-empty.
|
||||
OIDCIssuer string
|
||||
// OIDCClientID and OIDCClientSecret identify this app as a confidential
|
||||
// client of the issuer. An empty OIDCClientID disables auth entirely:
|
||||
// /api/auth/* answers 404 and /api/v1 stays open (standalone mode).
|
||||
OIDCClientID string
|
||||
OIDCClientSecret string
|
||||
// AuthSessionSecret signs session cookies. When unset, a random
|
||||
// ephemeral secret is generated (AuthSessionSecretEphemeral = true) —
|
||||
// every restart then invalidates all sessions.
|
||||
AuthSessionSecret string
|
||||
AuthSessionSecretEphemeral bool
|
||||
// AuthSessionTTL is how long a session cookie stays valid.
|
||||
AuthSessionTTL time.Duration
|
||||
}
|
||||
|
||||
// Load reads the environment and returns a validated Config.
|
||||
func Load() (Config, error) {
|
||||
cfg := Config{
|
||||
Addr: env("API_ADDR", ":8080"),
|
||||
DatabaseURL: env("DATABASE_URL", "sqlite:dev.db"),
|
||||
AllowedOrigins: splitCSV(env("CORS_ALLOWED_ORIGINS", "http://localhost:5173,http://127.0.0.1:5173")),
|
||||
LogFormat: strings.ToLower(env("LOG_FORMAT", "json")),
|
||||
DebugQueries: env("DB_DEBUG", "false") == "true",
|
||||
|
||||
OIDCIssuer: strings.TrimRight(env("OIDC_ISSUER", "https://users.k6n.net"), "/"),
|
||||
OIDCClientID: env("OIDC_CLIENT_ID", ""),
|
||||
OIDCClientSecret: env("OIDC_CLIENT_SECRET", ""),
|
||||
AuthSessionSecret: env("AUTH_SESSION_SECRET", ""),
|
||||
}
|
||||
|
||||
sessionTTL, ttlErr := time.ParseDuration(env("AUTH_SESSION_TTL", "168h"))
|
||||
if ttlErr == nil {
|
||||
cfg.AuthSessionTTL = sessionTTL
|
||||
}
|
||||
|
||||
var errs []error
|
||||
|
||||
level := strings.ToLower(env("LOG_LEVEL", "info"))
|
||||
switch level {
|
||||
case "debug":
|
||||
cfg.LogLevel = slog.LevelDebug
|
||||
case "info":
|
||||
cfg.LogLevel = slog.LevelInfo
|
||||
case "warn":
|
||||
cfg.LogLevel = slog.LevelWarn
|
||||
case "error":
|
||||
cfg.LogLevel = slog.LevelError
|
||||
default:
|
||||
errs = append(errs, fmt.Errorf("invalid LOG_LEVEL %q (want debug, info, warn, or error)", level))
|
||||
}
|
||||
|
||||
if cfg.Addr == "" {
|
||||
errs = append(errs, errors.New("API_ADDR must not be empty"))
|
||||
}
|
||||
if cfg.DatabaseURL == "" {
|
||||
errs = append(errs, errors.New("DATABASE_URL must not be empty"))
|
||||
}
|
||||
if cfg.LogFormat != "json" && cfg.LogFormat != "text" {
|
||||
errs = append(errs, fmt.Errorf("invalid LOG_FORMAT %q (want json or text)", cfg.LogFormat))
|
||||
}
|
||||
|
||||
if ttlErr != nil {
|
||||
errs = append(errs, fmt.Errorf("invalid AUTH_SESSION_TTL: %v", ttlErr))
|
||||
} else if cfg.AuthSessionTTL <= 0 {
|
||||
errs = append(errs, errors.New("AUTH_SESSION_TTL must be positive"))
|
||||
}
|
||||
if _, err := url.Parse(cfg.OIDCIssuer); err != nil || !strings.HasPrefix(cfg.OIDCIssuer, "https://") && !strings.HasPrefix(cfg.OIDCIssuer, "http://") {
|
||||
errs = append(errs, fmt.Errorf("invalid OIDC_ISSUER %q (want an http(s) URL)", cfg.OIDCIssuer))
|
||||
}
|
||||
if cfg.OIDCClientID != "" && cfg.OIDCClientSecret == "" {
|
||||
errs = append(errs, errors.New("OIDC_CLIENT_SECRET is required when OIDC_CLIENT_ID is set"))
|
||||
}
|
||||
if cfg.AuthSessionSecret == "" {
|
||||
secret, err := randomSecret()
|
||||
if err != nil {
|
||||
return Config{}, fmt.Errorf("generate session secret: %w", err)
|
||||
}
|
||||
cfg.AuthSessionSecret = secret
|
||||
cfg.AuthSessionSecretEphemeral = true
|
||||
}
|
||||
|
||||
if len(errs) > 0 {
|
||||
return Config{}, fmt.Errorf("invalid config: %w", errors.Join(errs...))
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// randomSecret returns a URL-safe random string for signing session
|
||||
// cookies when AUTH_SESSION_SECRET is not provided.
|
||||
func randomSecret() (string, error) {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
// env returns the environment variable or fallback when unset/blank.
|
||||
func env(key, fallback string) string {
|
||||
if v, ok := os.LookupEnv(key); ok && strings.TrimSpace(v) != "" {
|
||||
return strings.TrimSpace(v)
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// splitCSV splits a comma-separated env value, dropping blank entries.
|
||||
func splitCSV(v string) []string {
|
||||
parts := strings.Split(v, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
if p = strings.TrimSpace(p); p != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
// Package database owns the Bun connection and the migration registry.
|
||||
package database
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/uptrace/bun"
|
||||
"github.com/uptrace/bun/dialect/pgdialect"
|
||||
"github.com/uptrace/bun/dialect/sqlitedialect"
|
||||
"github.com/uptrace/bun/driver/pgdriver"
|
||||
"github.com/uptrace/bun/driver/sqliteshim"
|
||||
"github.com/uptrace/bun/extra/bundebug"
|
||||
"github.com/uptrace/bun/migrate"
|
||||
"github.com/uptrace/bun/schema"
|
||||
|
||||
"git.k6n.net/mats/fullstack-template/internal/config"
|
||||
migrations "git.k6n.net/mats/fullstack-template/internal/database/migrations"
|
||||
)
|
||||
|
||||
// Open connects to the database named by cfg.DatabaseURL:
|
||||
//
|
||||
// sqlite:FILE?params Bun + modernc sqlite (pure Go, no CGO) — dev default
|
||||
// postgres://… Bun + pgdriver against Postgres
|
||||
//
|
||||
// It does not ping — call PingContext during startup to fail fast.
|
||||
func Open(cfg config.Config) (*bun.DB, error) {
|
||||
var sqldb *sql.DB
|
||||
var dialect schema.Dialect
|
||||
|
||||
switch {
|
||||
case strings.HasPrefix(cfg.DatabaseURL, "sqlite:"):
|
||||
dsn := strings.TrimPrefix(cfg.DatabaseURL, "sqlite:")
|
||||
// Sensible dev defaults when the DSN brings no pragmas of its own:
|
||||
// WAL keeps readers unblocked during writes, busy_timeout waits out
|
||||
// the remaining write locks instead of erroring, foreign_keys is
|
||||
// what everyone expects to be on.
|
||||
if !strings.Contains(dsn, "_pragma") {
|
||||
sep := "?"
|
||||
if strings.Contains(dsn, "?") {
|
||||
sep = "&"
|
||||
}
|
||||
dsn += sep + "_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)"
|
||||
}
|
||||
var err error
|
||||
sqldb, err = sql.Open(sqliteshim.ShimName, dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open sqlite %q: %w", dsn, err)
|
||||
}
|
||||
dialect = sqlitedialect.New()
|
||||
default:
|
||||
sqldb = sql.OpenDB(pgdriver.NewConnector(pgdriver.WithDSN(cfg.DatabaseURL)))
|
||||
dialect = pgdialect.New()
|
||||
}
|
||||
|
||||
db := bun.NewDB(sqldb, dialect)
|
||||
if cfg.DebugQueries {
|
||||
db.AddQueryHook(bundebug.NewQueryHook(bundebug.WithVerbose(true)))
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// NewMigrator returns a bun migrator over the migrations registered in
|
||||
// internal/database/migrations. See cmd/migrate for the CLI around it.
|
||||
func NewMigrator(db *bun.DB) *migrate.Migrator {
|
||||
return migrate.NewMigrator(db, migrations.Migrations)
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package database_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"git.k6n.net/mats/fullstack-template/internal/config"
|
||||
"git.k6n.net/mats/fullstack-template/internal/database"
|
||||
"git.k6n.net/mats/fullstack-template/internal/items"
|
||||
)
|
||||
|
||||
// The whole persistence stack against an in-memory SQLite database:
|
||||
// migrations produce a working schema and the real BunRepository
|
||||
// round-trips items. This is also the cheapest way to catch dialect drift
|
||||
// between the model and the migrations — no Postgres container required.
|
||||
func TestSQLiteSchemaRoundTrip(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
db, err := database.Open(config.Config{DatabaseURL: "sqlite:file:mem?mode=memory&cache=shared"})
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
// One connection keeps the shared in-memory database stable across
|
||||
// the pool (and mirrors how single-writer sqlite behaves anyway).
|
||||
db.SetMaxOpenConns(1)
|
||||
|
||||
migrator := database.NewMigrator(db)
|
||||
if err := migrator.Init(ctx); err != nil {
|
||||
t.Fatalf("migrator init: %v", err)
|
||||
}
|
||||
if _, err := migrator.Migrate(ctx); err != nil {
|
||||
t.Fatalf("migrate up: %v", err)
|
||||
}
|
||||
|
||||
svc := items.NewService(items.NewBunRepository(db))
|
||||
|
||||
created, err := svc.Create(ctx, items.CreateInput{Title: "first", Description: "from sqlite"})
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
if created.ID == 0 || created.Status != items.StatusActive {
|
||||
t.Fatalf("create returned %+v", created)
|
||||
}
|
||||
|
||||
got, err := svc.Get(ctx, created.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get: %v", err)
|
||||
}
|
||||
if got.Title != "first" || got.CreatedAt.IsZero() {
|
||||
t.Fatalf("get returned %+v", got)
|
||||
}
|
||||
|
||||
archived, err := svc.Update(ctx, created.ID, items.UpdateInput{Status: ptr(items.StatusArchived)})
|
||||
if err != nil {
|
||||
t.Fatalf("update: %v", err)
|
||||
}
|
||||
if archived.Status != items.StatusArchived {
|
||||
t.Fatalf("update returned %+v", archived)
|
||||
}
|
||||
|
||||
listed, err := svc.List(ctx, items.Filter{Status: items.StatusArchived})
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if len(listed) != 1 {
|
||||
t.Fatalf("list archived len = %d, want 1", len(listed))
|
||||
}
|
||||
|
||||
if err := svc.Delete(ctx, created.ID); err != nil {
|
||||
t.Fatalf("delete: %v", err)
|
||||
}
|
||||
if _, err := svc.Get(ctx, created.ID); !errors.Is(err, items.ErrNotFound) {
|
||||
t.Fatalf("get after delete: got %v, want ErrNotFound", err)
|
||||
}
|
||||
|
||||
// The down migration must undo the up migration cleanly.
|
||||
if _, err := migrator.Rollback(ctx); err != nil {
|
||||
t.Fatalf("rollback: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func ptr[T any](v T) *T { return &v }
|
||||
@@ -0,0 +1,31 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/uptrace/bun"
|
||||
|
||||
"git.k6n.net/mats/fullstack-template/internal/items"
|
||||
)
|
||||
|
||||
func init() {
|
||||
Migrations.MustRegister(func(ctx context.Context, db *bun.DB) error {
|
||||
_, err := db.NewCreateTable().
|
||||
Model((*items.Item)(nil)).
|
||||
IfNotExists().
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = db.NewCreateIndex().
|
||||
Model((*items.Item)(nil)).
|
||||
IfNotExists().
|
||||
Index("items_status_idx").
|
||||
Column("status").
|
||||
Exec(ctx)
|
||||
return err
|
||||
}, func(ctx context.Context, db *bun.DB) error {
|
||||
_, err := db.NewDropTable().Model((*items.Item)(nil)).IfExists().Exec(ctx)
|
||||
return err
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// Package migrations holds the schema migrations: one Go file per
|
||||
// migration, named <YYYYMMDDHHMMSS>_<snake_name>.go, each registering
|
||||
// itself into Migrations via init(). `make migrate-new` scaffolds the next
|
||||
// one with a fresh timestamp.
|
||||
//
|
||||
// Prefer model-driven DDL (db.NewCreateTable().Model((*pkg.Thing)(nil))) —
|
||||
// bun emits dialect-correct SQL for both sqlite and postgres, so migrations
|
||||
// stay portable. Reach for db.Exec with dialect-specific SQL only when a
|
||||
// model can't express it.
|
||||
package migrations
|
||||
|
||||
import "github.com/uptrace/bun/migrate"
|
||||
|
||||
// Migrations is the registry consumed by internal/database.NewMigrator.
|
||||
// The directory option tells `migrate new` where to write (from backend/).
|
||||
var Migrations = migrate.NewMigrations(
|
||||
migrate.WithMigrationsDirectory("internal/database/migrations"),
|
||||
)
|
||||
@@ -0,0 +1,89 @@
|
||||
// Package items is the reference vertical slice: model, repository
|
||||
// interface, Bun implementation, and service. To add a new resource, copy
|
||||
// this package's shape and register its routes in internal/api.
|
||||
package items
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
// Status is the lifecycle state of an item, stored as text.
|
||||
type Status string
|
||||
|
||||
const (
|
||||
StatusActive Status = "active"
|
||||
StatusArchived Status = "archived"
|
||||
)
|
||||
|
||||
// Valid reports whether s is a known status value.
|
||||
func (s Status) Valid() bool {
|
||||
return s == StatusActive || s == StatusArchived
|
||||
}
|
||||
|
||||
// Item is the persisted model. Struct tags are Bun's mapping; json tags
|
||||
// define the wire format (camelCase, matching the frontend).
|
||||
type Item struct {
|
||||
bun.BaseModel `bun:"table:items,alias:i"`
|
||||
|
||||
ID int64 `bun:"id,pk,autoincrement" json:"id"`
|
||||
Title string `bun:"title,notnull" json:"title"`
|
||||
Description string `bun:"description,nullzero" json:"description"`
|
||||
Status Status `bun:"status,notnull,default:'active'" json:"status"`
|
||||
CreatedAt time.Time `bun:"created_at,notnull,default:current_timestamp" json:"createdAt"`
|
||||
UpdatedAt time.Time `bun:"updated_at,notnull,default:current_timestamp" json:"updatedAt"`
|
||||
}
|
||||
|
||||
// BeforeAppendModel stamps timestamps on INSERT and UPDATE queries.
|
||||
// Postgres stores microsecond UTC; truncate + UTC so values set in Go
|
||||
// (PATCH responses) match what round-trips through the database.
|
||||
func (i *Item) BeforeAppendModel(_ context.Context, query bun.Query) error {
|
||||
now := time.Now().UTC().Truncate(time.Microsecond)
|
||||
switch query.(type) {
|
||||
case *bun.InsertQuery:
|
||||
i.CreatedAt, i.UpdatedAt = now, now
|
||||
case *bun.UpdateQuery:
|
||||
i.UpdatedAt = now
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Filter narrows and pages List queries. Apply normalizeFilter (via the
|
||||
// service) before handing one to a repository.
|
||||
type Filter struct {
|
||||
Status Status
|
||||
Limit int
|
||||
Offset int
|
||||
}
|
||||
|
||||
// List result paging defaults, applied by normalizeFilter.
|
||||
const (
|
||||
DefaultLimit = 50
|
||||
MaxLimit = 100
|
||||
)
|
||||
|
||||
// Repository is the persistence contract for items. The service depends on
|
||||
// this interface — not on Bun — so tests use in-memory fakes and other
|
||||
// backends remain possible.
|
||||
type Repository interface {
|
||||
List(ctx context.Context, f Filter) ([]Item, error)
|
||||
Get(ctx context.Context, id int64) (Item, error)
|
||||
Create(ctx context.Context, item *Item) error
|
||||
Update(ctx context.Context, item *Item) error
|
||||
Delete(ctx context.Context, id int64) error
|
||||
}
|
||||
|
||||
// ErrNotFound is returned by repositories and services when an item does
|
||||
// not exist. Handlers map it to 404.
|
||||
var ErrNotFound = errors.New("item not found")
|
||||
|
||||
// ValidationError reports an invalid field value. Handlers map it to 400.
|
||||
type ValidationError struct {
|
||||
Field string
|
||||
Reason string
|
||||
}
|
||||
|
||||
func (e *ValidationError) Error() string { return e.Field + ": " + e.Reason }
|
||||
@@ -0,0 +1,81 @@
|
||||
package items
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
// BunRepository is the Postgres implementation of Repository. It is the
|
||||
// only file in the package that speaks Bun/SQL.
|
||||
type BunRepository struct {
|
||||
db *bun.DB
|
||||
}
|
||||
|
||||
// NewBunRepository wraps a *bun.DB opened by internal/database.
|
||||
func NewBunRepository(db *bun.DB) *BunRepository {
|
||||
return &BunRepository{db: db}
|
||||
}
|
||||
|
||||
// List returns items newest-first, honoring the (already normalized) filter.
|
||||
// An empty result set encodes as [] on the wire, not null.
|
||||
func (r *BunRepository) List(ctx context.Context, f Filter) ([]Item, error) {
|
||||
var out []Item
|
||||
q := r.db.NewSelect().Model(&out)
|
||||
if f.Status != "" {
|
||||
q = q.Where("i.status = ?", f.Status)
|
||||
}
|
||||
if err := q.
|
||||
Order("i.created_at DESC", "i.id DESC").
|
||||
Limit(f.Limit).
|
||||
Offset(f.Offset).
|
||||
Scan(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if out == nil {
|
||||
out = []Item{}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Get fetches a single item by id, mapping missing rows to ErrNotFound.
|
||||
func (r *BunRepository) Get(ctx context.Context, id int64) (Item, error) {
|
||||
var item Item
|
||||
err := r.db.NewSelect().Model(&item).Where("i.id = ?", id).Scan(ctx)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Item{}, ErrNotFound
|
||||
}
|
||||
return item, err
|
||||
}
|
||||
|
||||
// Create inserts item and reads back the generated columns.
|
||||
func (r *BunRepository) Create(ctx context.Context, item *Item) error {
|
||||
return r.db.NewInsert().Model(item).Returning("*").Scan(ctx, item)
|
||||
}
|
||||
|
||||
// Update persists item (matched by primary key); ErrNotFound when the row
|
||||
// is gone.
|
||||
func (r *BunRepository) Update(ctx context.Context, item *Item) error {
|
||||
res, err := r.db.NewUpdate().Model(item).WherePK().Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete removes an item by id; ErrNotFound when the row is gone.
|
||||
func (r *BunRepository) Delete(ctx context.Context, id int64) error {
|
||||
res, err := r.db.NewDelete().Model((*Item)(nil)).Where("i.id = ?", id).Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package items
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Field length limits enforced on input.
|
||||
const (
|
||||
maxTitleLen = 200
|
||||
maxDescriptionLen = 2000
|
||||
)
|
||||
|
||||
// Service holds the business rules for items. It depends only on the
|
||||
// Repository interface; main wires the Bun implementation in.
|
||||
type Service struct {
|
||||
repo Repository
|
||||
}
|
||||
|
||||
// NewService returns a Service over the given repository.
|
||||
func NewService(repo Repository) *Service {
|
||||
return &Service{repo: repo}
|
||||
}
|
||||
|
||||
// CreateInput is the request body for creating an item.
|
||||
type CreateInput struct {
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Status Status `json:"status"`
|
||||
}
|
||||
|
||||
// UpdateInput patches only provided fields (nil = leave untouched), which
|
||||
// is why every field is a pointer.
|
||||
type UpdateInput struct {
|
||||
Title *string `json:"title"`
|
||||
Description *string `json:"description"`
|
||||
Status *Status `json:"status"`
|
||||
}
|
||||
|
||||
// List validates the filter and delegates to the repository.
|
||||
func (s *Service) List(ctx context.Context, f Filter) ([]Item, error) {
|
||||
f, err := normalizeFilter(f)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.repo.List(ctx, f)
|
||||
}
|
||||
|
||||
// Get returns one item.
|
||||
func (s *Service) Get(ctx context.Context, id int64) (Item, error) {
|
||||
return s.repo.Get(ctx, id)
|
||||
}
|
||||
|
||||
// Create validates input and stores a new item.
|
||||
func (s *Service) Create(ctx context.Context, in CreateInput) (Item, error) {
|
||||
item := Item{
|
||||
Title: strings.TrimSpace(in.Title),
|
||||
Description: strings.TrimSpace(in.Description),
|
||||
}
|
||||
if item.Title == "" {
|
||||
return Item{}, &ValidationError{Field: "title", Reason: "required"}
|
||||
}
|
||||
if len(item.Title) > maxTitleLen {
|
||||
return Item{}, &ValidationError{Field: "title", Reason: "must be at most 200 characters"}
|
||||
}
|
||||
if len(item.Description) > maxDescriptionLen {
|
||||
return Item{}, &ValidationError{Field: "description", Reason: "must be at most 2000 characters"}
|
||||
}
|
||||
|
||||
switch in.Status {
|
||||
case "":
|
||||
item.Status = StatusActive
|
||||
case StatusActive, StatusArchived:
|
||||
item.Status = in.Status
|
||||
default:
|
||||
return Item{}, &ValidationError{Field: "status", Reason: "must be active or archived"}
|
||||
}
|
||||
|
||||
if err := s.repo.Create(ctx, &item); err != nil {
|
||||
return Item{}, err
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
// Update applies a partial patch to an existing item.
|
||||
func (s *Service) Update(ctx context.Context, id int64, in UpdateInput) (Item, error) {
|
||||
if in.Title == nil && in.Description == nil && in.Status == nil {
|
||||
return Item{}, &ValidationError{Field: "body", Reason: "at least one field must be set"}
|
||||
}
|
||||
|
||||
item, err := s.repo.Get(ctx, id)
|
||||
if err != nil {
|
||||
return Item{}, err
|
||||
}
|
||||
|
||||
if in.Title != nil {
|
||||
item.Title = strings.TrimSpace(*in.Title)
|
||||
if item.Title == "" {
|
||||
return Item{}, &ValidationError{Field: "title", Reason: "required"}
|
||||
}
|
||||
if len(item.Title) > maxTitleLen {
|
||||
return Item{}, &ValidationError{Field: "title", Reason: "must be at most 200 characters"}
|
||||
}
|
||||
}
|
||||
if in.Description != nil {
|
||||
item.Description = strings.TrimSpace(*in.Description)
|
||||
if len(item.Description) > maxDescriptionLen {
|
||||
return Item{}, &ValidationError{Field: "description", Reason: "must be at most 2000 characters"}
|
||||
}
|
||||
}
|
||||
if in.Status != nil {
|
||||
if !in.Status.Valid() {
|
||||
return Item{}, &ValidationError{Field: "status", Reason: "must be active or archived"}
|
||||
}
|
||||
item.Status = *in.Status
|
||||
}
|
||||
|
||||
if err := s.repo.Update(ctx, &item); err != nil {
|
||||
return Item{}, err
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
// Delete removes an item.
|
||||
func (s *Service) Delete(ctx context.Context, id int64) error {
|
||||
return s.repo.Delete(ctx, id)
|
||||
}
|
||||
|
||||
// normalizeFilter applies paging defaults and validates the status filter.
|
||||
func normalizeFilter(f Filter) (Filter, error) {
|
||||
switch f.Status {
|
||||
case "":
|
||||
case StatusActive, StatusArchived:
|
||||
default:
|
||||
return Filter{}, &ValidationError{Field: "status", Reason: "must be active or archived"}
|
||||
}
|
||||
if f.Limit <= 0 {
|
||||
f.Limit = DefaultLimit
|
||||
}
|
||||
if f.Limit > MaxLimit {
|
||||
f.Limit = MaxLimit
|
||||
}
|
||||
if f.Offset < 0 {
|
||||
f.Offset = 0
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
package items_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.k6n.net/mats/fullstack-template/internal/items"
|
||||
)
|
||||
|
||||
// fakeRepo is an in-memory items.Repository — the point of the Repository
|
||||
// interface is that these tests never touch a database.
|
||||
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
|
||||
}
|
||||
|
||||
func newService() (*items.Service, *fakeRepo) {
|
||||
repo := newFakeRepo()
|
||||
return items.NewService(repo), repo
|
||||
}
|
||||
|
||||
func TestCreateValidation(t *testing.T) {
|
||||
svc, _ := newService()
|
||||
ctx := context.Background()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
in items.CreateInput
|
||||
want string // expected ValidationError.Field
|
||||
}{
|
||||
{"empty title", items.CreateInput{Title: " "}, "title"},
|
||||
{"long title", items.CreateInput{Title: strings.Repeat("x", 201)}, "title"},
|
||||
{"bad status", items.CreateInput{Title: "ok", Status: "nope"}, "status"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := svc.Create(ctx, tt.in)
|
||||
var vErr *items.ValidationError
|
||||
if !errors.As(err, &vErr) {
|
||||
t.Fatalf("got error %v, want *items.ValidationError", err)
|
||||
}
|
||||
if vErr.Field != tt.want {
|
||||
t.Errorf("field = %q, want %q", vErr.Field, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateDefaults(t *testing.T) {
|
||||
svc, _ := newService()
|
||||
|
||||
item, err := svc.Create(context.Background(), items.CreateInput{Title: " hello "})
|
||||
if err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
if item.Title != "hello" {
|
||||
t.Errorf("title = %q, want trimmed %q", item.Title, "hello")
|
||||
}
|
||||
if item.Status != items.StatusActive {
|
||||
t.Errorf("status = %q, want default %q", item.Status, items.StatusActive)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdatePatchesOnlyProvidedFields(t *testing.T) {
|
||||
svc, _ := newService()
|
||||
ctx := context.Background()
|
||||
|
||||
created, err := svc.Create(ctx, items.CreateInput{Title: "original", Description: "keep me"})
|
||||
if err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
|
||||
archived := items.StatusArchived
|
||||
updated, err := svc.Update(ctx, created.ID, items.UpdateInput{Status: &archived})
|
||||
if err != nil {
|
||||
t.Fatalf("Update: %v", err)
|
||||
}
|
||||
if updated.Status != items.StatusArchived {
|
||||
t.Errorf("status = %q, want %q", updated.Status, items.StatusArchived)
|
||||
}
|
||||
if updated.Title != "original" || updated.Description != "keep me" {
|
||||
t.Errorf("untouched fields changed: %+v", updated)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateRequiresField(t *testing.T) {
|
||||
svc, _ := newService()
|
||||
ctx := context.Background()
|
||||
|
||||
created, _ := svc.Create(ctx, items.CreateInput{Title: "x"})
|
||||
_, err := svc.Update(ctx, created.ID, items.UpdateInput{})
|
||||
var vErr *items.ValidationError
|
||||
if !errors.As(err, &vErr) || vErr.Field != "body" {
|
||||
t.Fatalf("got %v, want validation error on body", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateNotFound(t *testing.T) {
|
||||
svc, _ := newService()
|
||||
title := "x"
|
||||
_, err := svc.Update(context.Background(), 999, items.UpdateInput{Title: &title})
|
||||
if !errors.Is(err, items.ErrNotFound) {
|
||||
t.Fatalf("got %v, want ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListFilterAndPaging(t *testing.T) {
|
||||
svc, _ := newService()
|
||||
ctx := context.Background()
|
||||
|
||||
svc.Create(ctx, items.CreateInput{Title: "one"})
|
||||
svc.Create(ctx, items.CreateInput{Title: "two", Status: items.StatusArchived})
|
||||
svc.Create(ctx, items.CreateInput{Title: "three", Status: items.StatusArchived})
|
||||
|
||||
got, err := svc.List(ctx, items.Filter{Status: items.StatusArchived})
|
||||
if err != nil {
|
||||
t.Fatalf("List: %v", err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("len = %d, want 2", len(got))
|
||||
}
|
||||
|
||||
if _, err := svc.List(ctx, items.Filter{Status: "bogus"}); err == nil {
|
||||
t.Error("List with bogus status: want validation error")
|
||||
}
|
||||
|
||||
got, err = svc.List(ctx, items.Filter{Limit: 1})
|
||||
if err != nil {
|
||||
t.Fatalf("List: %v", err)
|
||||
}
|
||||
if len(got) != items.DefaultLimit && len(got) != 1 {
|
||||
t.Fatalf("len = %d, want 1", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteFlow(t *testing.T) {
|
||||
svc, _ := newService()
|
||||
ctx := context.Background()
|
||||
|
||||
item, _ := svc.Create(ctx, items.CreateInput{Title: "gone"})
|
||||
if err := svc.Delete(ctx, item.ID); err != nil {
|
||||
t.Fatalf("Delete: %v", err)
|
||||
}
|
||||
if _, err := svc.Get(ctx, item.ID); !errors.Is(err, items.ErrNotFound) {
|
||||
t.Fatalf("Get after delete: got %v, want ErrNotFound", err)
|
||||
}
|
||||
if err := svc.Delete(ctx, item.ID); !errors.Is(err, items.ErrNotFound) {
|
||||
t.Fatalf("second delete: got %v, want ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user