refac: removed 7 direct and 12 indirect go modules for custom code instead

This commit is contained in:
Florian Sylvain committed 2026-10-21 23:13:46 +02:00
1 parent cfcf108488
commit c68e9a7858
73 files changed
+2455 -428

No files matched your search

+2 -2
View File
@@ -16,9 +16,9 @@ import (
"testing"
"time"
"RenewCMS/api/pkg/authtoken"
"github.com/glebarez/sqlite"
"github.com/go-chi/chi/v5"
"github.com/go-chi/jwtauth/v5"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
@@ -117,7 +117,7 @@ func newAtomicAcceptFixture(t *testing.T, wrap func(db *gorm.DB, repo invite.Rep
handler := auth.NewAcceptInviteHandler(
useCases.NewGetInviteUseCase(invites, hasher),
useCases.NewAcceptInviteUseCase(users, invites, hasher, discardRecorder),
jwtauth.New("HS256", []byte("test-secret"), nil),
authtoken.New([]byte("test-secret")),
true,
)
router := chi.NewRouter()
+9 -14
View File
@@ -17,9 +17,8 @@ import (
"testing"
"time"
"RenewCMS/api/pkg/authtoken"
"github.com/go-chi/chi/v5"
"github.com/go-chi/jwtauth/v5"
"github.com/lestrrat-go/jwx/v3/transform"
)
const (
@@ -39,7 +38,7 @@ type acceptInviteFixture struct {
mail *mockMailRepo
hasher *stubHasher
router *chi.Mux
token *jwtauth.JWTAuth
token *authtoken.Auth
}
func newAcceptInviteFixture(t *testing.T, maxAttempts int) *acceptInviteFixture {
@@ -50,7 +49,7 @@ func newAcceptInviteFixture(t *testing.T, maxAttempts int) *acceptInviteFixture
invites: newInviteFakeRepo(),
mail: newMockMailRepo(),
hasher: &stubHasher{},
token: jwtauth.New("HS256", []byte("test-secret"), nil),
token: authtoken.New([]byte("test-secret")),
}
f.invites.users = f.users
getUser := useCases.NewGetUserUseCase(f.users)
@@ -85,15 +84,15 @@ func newAcceptInviteFixture(t *testing.T, maxAttempts int) *acceptInviteFixture
r.Post("/invite/accept", acceptHandler.AcceptInvite)
})
r.Group(func(r chi.Router) {
r.Use(jwtauth.Verifier(f.token))
r.Use(jwtauth.Authenticator(f.token))
r.Use(authtoken.Verifier(f.token))
r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(getUser))
r.Get("/me", meHandler.Me)
})
})
f.router.Route("/v1/user", func(r chi.Router) {
r.Use(jwtauth.Verifier(f.token))
r.Use(jwtauth.Authenticator(f.token))
r.Use(authtoken.Verifier(f.token))
r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(getUser))
r.Use(middleware.RequireVerifiedUser(getUser))
r.Get("/", listHandler.ListUsers)
@@ -258,12 +257,10 @@ func TestAcceptInvitationHttp(t *testing.T) {
if lifetime := time.Until(cookie.Expires); lifetime < 23*time.Hour || lifetime > 24*time.Hour+time.Minute {
t.Errorf("expected a 24 hour cookie, got %v", lifetime)
}
decoded, err := f.token.Decode(cookie.Value)
claims, err := f.token.Decode(cookie.Value)
if err != nil {
t.Fatalf("cookie does not decode: %v", err)
}
claims := map[string]interface{}{}
_ = transform.AsMap(decoded, claims)
if claims["user_id"] != float64(created.ID) || claims["token_version"] != float64(created.TokenVersion) {
t.Errorf("unexpected claims %v", claims)
}
@@ -414,12 +411,10 @@ func TestAcceptInvitationHttp(t *testing.T) {
}
created, _ := f.userNamed("lea")
cookie := jwtCookieOf(rec)
decoded, err := f.token.Decode(cookie.Value)
claims, err := f.token.Decode(cookie.Value)
if err != nil {
t.Fatalf("cookie does not decode: %v", err)
}
claims := map[string]interface{}{}
_ = transform.AsMap(decoded, claims)
if claims["user_id"] != float64(created.ID) || created.ID == 7 {
t.Errorf("cookie must carry the new user's id, got %v", claims["user_id"])
}
+9 -9
View File
@@ -13,11 +13,11 @@ import (
"testing"
"time"
"RenewCMS/api/pkg/authtoken"
"github.com/go-chi/chi/v5"
"github.com/go-chi/jwtauth/v5"
)
func newAccountVerificationRouter(userRepo *mockUserRepo, token *jwtauth.JWTAuth) *chi.Mux {
func newAccountVerificationRouter(userRepo *mockUserRepo, token *authtoken.Auth) *chi.Mux {
hasher := &stubHasher{}
verificationHandler := auth.NewVerificationHandler(
useCases.NewVerifyUserUseCase(userRepo, hasher, discardRecorder),
@@ -29,8 +29,8 @@ func newAccountVerificationRouter(userRepo *mockUserRepo, token *jwtauth.JWTAuth
r.Route("/v1", func(r chi.Router) {
r.Route("/auth", func(r chi.Router) {
r.Group(func(r chi.Router) {
r.Use(jwtauth.Verifier(token))
r.Use(jwtauth.Authenticator(token))
r.Use(authtoken.Verifier(token))
r.Use(authtoken.Authenticator)
r.Post("/verify", verificationHandler.Verify)
})
})
@@ -51,11 +51,11 @@ func newAccountVerificationTestUserRepo() *mockUserRepo {
return repo
}
func sendVerifyRequest(router *chi.Mux, token *jwtauth.JWTAuth, claims map[string]any, code string) *httptest.ResponseRecorder {
func sendVerifyRequest(router *chi.Mux, token *authtoken.Auth, claims map[string]any, code string) *httptest.ResponseRecorder {
body, _ := json.Marshal(map[string]string{"code": code})
req := httptest.NewRequest(http.MethodPost, "/v1/auth/verify", bytes.NewReader(body))
if claims != nil {
_, tokenString, _ := token.Encode(claims)
tokenString, _ := token.Encode(claims)
req.AddCookie(&http.Cookie{Name: "jwt", Value: tokenString})
}
rec := httptest.NewRecorder()
@@ -65,7 +65,7 @@ func sendVerifyRequest(router *chi.Mux, token *jwtauth.JWTAuth, claims map[strin
func TestAccountVerificationHttp(t *testing.T) {
validator.InitValidator()
token := jwtauth.New("HS256", []byte("test-secret"), nil)
token := authtoken.New([]byte("test-secret"))
t.Run("A logged-in user verifies their account with the correct emailed code", func(t *testing.T) {
repo := newAccountVerificationTestUserRepo()
@@ -200,7 +200,7 @@ func TestAccountVerificationHttp(t *testing.T) {
router := newAccountVerificationRouter(repo, token)
req := httptest.NewRequest(http.MethodPost, "/v1/auth/verify", bytes.NewReader([]byte("not-json")))
_, tokenString, _ := token.Encode(map[string]any{"user_id": uint32(7)})
tokenString, _ := token.Encode(map[string]any{"user_id": uint32(7)})
req.AddCookie(&http.Cookie{Name: "jwt", Value: tokenString})
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
@@ -224,7 +224,7 @@ func TestAccountVerificationHttp(t *testing.T) {
}
})
t.Run("A request with no jwt cookie is answered 401 by jwtauth itself", func(t *testing.T) {
t.Run("A request with no jwt cookie is answered 401 by the authenticator itself", func(t *testing.T) {
repo := newAccountVerificationTestUserRepo()
router := newAccountVerificationRouter(repo, token)
+5 -5
View File
@@ -23,8 +23,8 @@ import (
"testing"
"time"
"RenewCMS/api/pkg/authtoken"
"github.com/go-chi/chi/v5"
"github.com/go-chi/jwtauth/v5"
)
const (
@@ -44,7 +44,7 @@ type activityHTTPEnv struct {
keys *mockAPIKeyRepo
articles *mockArticleRepo
mail *mockMailRepo
token *jwtauth.JWTAuth
token *authtoken.Auth
generator *hashing.SHA256KeyGenerator
router http.Handler
}
@@ -59,7 +59,7 @@ func newActivityHTTPEnv(t *testing.T, attempts int) *activityHTTPEnv {
keys: newMockAPIKeyRepo(),
articles: newMockArticleRepo(),
mail: newMockMailRepo(),
token: jwtauth.New("HS256", []byte("test-secret"), nil),
token: authtoken.New([]byte("test-secret")),
generator: hashing.NewSHA256KeyGenerator(),
}
e.users.users[activityOwnerID] = user.User{ID: activityOwnerID, Username: "marta", DisplayName: "Marta Alves", Email: "marta@example.com", Password: "hashed:" + activityPassword, IsVerified: true, Role: user.RoleOwner}
@@ -99,7 +99,7 @@ type activityRequest func(*http.Request)
func asUser(e *activityHTTPEnv, id uint32) activityRequest {
return func(r *http.Request) {
_, tokenString, _ := e.token.Encode(map[string]any{"user_id": id, "token_version": 0})
tokenString, _ := e.token.Encode(map[string]any{"user_id": id, "token_version": 0})
r.AddCookie(&http.Cookie{Name: "jwt", Value: tokenString})
}
}
@@ -1037,7 +1037,7 @@ func TestActivityLogBackendAuditContext(t *testing.T) {
t.Helper()
probe := &auditOriginProbe{}
r := chi.NewRouter()
r.Use(jwtauth.Verifier(e.token))
r.Use(authtoken.Verifier(e.token))
r.Use(middleware.APIKeyAuth(useCases.NewAuthenticateAPIKeyUseCase(e.keys, e.generator, e.users, e.clock), 100, time.Minute))
r.Use(middleware.AuditContext(useCases.NewGetUserUseCase(e.users), trusted))
r.Get("/probe", func(w http.ResponseWriter, req *http.Request) {
+4 -4
View File
@@ -27,8 +27,8 @@ import (
"testing"
"time"
"RenewCMS/api/pkg/authtoken"
"github.com/go-chi/chi/v5"
"github.com/go-chi/jwtauth/v5"
)
const (
@@ -51,7 +51,7 @@ type apiKeyEnv struct {
mail *mockMailRepo
feed *fakeReleaseFeed
updates *fakeUpdateStore
token *jwtauth.JWTAuth
token *authtoken.Auth
generator *hashing.SHA256KeyGenerator
now time.Time
settings config.Settings
@@ -76,7 +76,7 @@ func newAPIKeyEnvRecording(t *testing.T, recorder audit.Recorder) *apiKeyEnv {
images: newMockImageRepo(),
invites: newInviteFakeRepo(),
updates: &fakeUpdateStore{},
token: jwtauth.New("HS256", []byte("test-secret"), nil),
token: authtoken.New([]byte("test-secret")),
generator: hashing.NewSHA256KeyGenerator(),
now: time.Date(2026, time.October, 4, 12, 0, 0, 0, time.UTC),
settings: config.Settings{AuthRateLimit: config.AuthRateLimitSettings{MaxAttempts: 1000, Window: time.Minute}},
@@ -172,7 +172,7 @@ type apiKeyRequest struct {
func (e *apiKeyEnv) cookie(t *testing.T, userId uint32, tokenVersion uint32) *http.Cookie {
t.Helper()
_, tokenString, err := e.token.Encode(map[string]any{"user_id": userId, "token_version": tokenVersion})
tokenString, err := e.token.Encode(map[string]any{"user_id": userId, "token_version": tokenVersion})
if err != nil {
t.Fatalf("failed to encode jwt: %v", err)
}
+5 -5
View File
@@ -15,8 +15,8 @@ import (
"net/http/httptest"
"testing"
"RenewCMS/api/pkg/authtoken"
"github.com/go-chi/chi/v5"
"github.com/go-chi/jwtauth/v5"
)
type fakeBodyRenderer struct {
@@ -40,7 +40,7 @@ const (
bodyFormatArticleOneJSON = `[{"type":"heading","level":2,"children":[{"type":"text","text":"Plan","marks":[]}]},{"type":"paragraph","children":[{"type":"text","text":"Replace the ","marks":[]},{"type":"text","text":"old","marks":[{"type":"bold"}]},{"type":"text","text":" deck.","marks":[]}]}]`
)
func newBodyFormatRouter(articleRepo *mockArticleRepo, userRepo *mockUserRepo, renderer domainArticle.BodyRenderer, token *jwtauth.JWTAuth) *chi.Mux {
func newBodyFormatRouter(articleRepo *mockArticleRepo, userRepo *mockUserRepo, renderer domainArticle.BodyRenderer, token *authtoken.Auth) *chi.Mux {
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
articleHandler := article.NewHandler(
useCases.NewCreateArticleUseCase(articleRepo, renderer, discardRecorder, plainSites(), plainAuthors()),
@@ -54,11 +54,11 @@ func newBodyFormatRouter(articleRepo *mockArticleRepo, userRepo *mockUserRepo, r
r := chi.NewRouter()
r.Route("/v1", func(r chi.Router) {
r.Route("/article", func(r chi.Router) {
r.Use(jwtauth.Verifier(token))
r.Use(authtoken.Verifier(token))
r.Get("/", articleHandler.ListArticles)
r.Get("/{id}", articleHandler.GetArticle)
r.Group(func(r chi.Router) {
r.Use(jwtauth.Authenticator(token))
r.Use(authtoken.Authenticator)
r.Use(middleware.RequireVerifiedUser(getUserUseCase))
r.Post("/", articleHandler.PostArticle)
r.Patch("/{id}", articleHandler.UpdateArticleBody)
@@ -145,7 +145,7 @@ func assertSameJSON(t *testing.T, actual, expected string) {
func TestArticleBodyFormatHttp(t *testing.T) {
validator.InitValidator()
token := jwtauth.New("HS256", []byte("test-secret"), nil)
token := authtoken.New([]byte("test-secret"))
newRouter := func(repo *mockArticleRepo, users *mockUserRepo, renderer domainArticle.BodyRenderer) *chi.Mux {
return newBodyFormatRouter(repo, users, renderer, token)
}
+6 -6
View File
@@ -14,11 +14,11 @@ import (
"strings"
"testing"
"RenewCMS/api/pkg/authtoken"
"github.com/go-chi/chi/v5"
"github.com/go-chi/jwtauth/v5"
)
func newUnlimitedBodyRouter(articleRepo *mockArticleRepo, token *jwtauth.JWTAuth) *chi.Mux {
func newUnlimitedBodyRouter(articleRepo *mockArticleRepo, token *authtoken.Auth) *chi.Mux {
getUserUseCase := useCases.NewGetUserUseCase(newVerifiedUserRepo())
renderer := markdown.NewConverter()
articleHandler := article.NewHandler(
@@ -34,9 +34,9 @@ func newUnlimitedBodyRouter(articleRepo *mockArticleRepo, token *jwtauth.JWTAuth
r.Route("/v1", func(r chi.Router) {
r.Route("/article", func(r chi.Router) {
r.Group(func(r chi.Router) {
r.Use(jwtauth.Verifier(token))
r.Use(authtoken.Verifier(token))
r.Group(func(r chi.Router) {
r.Use(jwtauth.Authenticator(token))
r.Use(authtoken.Authenticator)
r.Use(middleware.RequireVerifiedUser(getUserUseCase))
r.Post("/", articleHandler.PostArticle)
r.Get("/{id}", articleHandler.GetArticle)
@@ -48,7 +48,7 @@ func newUnlimitedBodyRouter(articleRepo *mockArticleRepo, token *jwtauth.JWTAuth
return r
}
func serveUnlimitedBody(t *testing.T, router *chi.Mux, token *jwtauth.JWTAuth, method, target, payload string) *httptest.ResponseRecorder {
func serveUnlimitedBody(t *testing.T, router *chi.Mux, token *authtoken.Auth, method, target, payload string) *httptest.ResponseRecorder {
t.Helper()
req := newEditArticleBodyRequest(method, target, payload)
setEditArticleBodyJwtCookie(t, req, token)
@@ -59,7 +59,7 @@ func serveUnlimitedBody(t *testing.T, router *chi.Mux, token *jwtauth.JWTAuth, m
func TestArticleBodyUnlimitedHttp(t *testing.T) {
validator.InitValidator()
token := jwtauth.New("HS256", []byte("test-secret"), nil)
token := authtoken.New([]byte("test-secret"))
const title = "Choosing tiles for a small bathroom"
createCases := []struct {
+7 -7
View File
@@ -14,11 +14,11 @@ import (
"net/http/httptest"
"testing"
"RenewCMS/api/pkg/authtoken"
"github.com/go-chi/chi/v5"
"github.com/go-chi/jwtauth/v5"
)
func newArticlePublishingRouter(articleRepo *mockArticleRepo, token *jwtauth.JWTAuth) *chi.Mux {
func newArticlePublishingRouter(articleRepo *mockArticleRepo, token *authtoken.Auth) *chi.Mux {
userRepo := newVerifiedUserRepo()
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
@@ -35,10 +35,10 @@ func newArticlePublishingRouter(articleRepo *mockArticleRepo, token *jwtauth.JWT
r.Route("/v1", func(r chi.Router) {
r.Route("/article", func(r chi.Router) {
r.Group(func(r chi.Router) {
r.Use(jwtauth.Verifier(token))
r.Use(authtoken.Verifier(token))
r.Get("/", articleHandler.ListArticles)
r.Group(func(r chi.Router) {
r.Use(jwtauth.Authenticator(token))
r.Use(authtoken.Authenticator)
r.Use(middleware.RequireVerifiedUser(getUserUseCase))
r.Get("/{id}", articleHandler.GetArticle)
r.Post("/", articleHandler.PostArticle)
@@ -59,9 +59,9 @@ func newPublishingTestArticleRepo() *mockArticleRepo {
return repo
}
func setPublishingJwtCookie(t *testing.T, req *http.Request, token *jwtauth.JWTAuth) {
func setPublishingJwtCookie(t *testing.T, req *http.Request, token *authtoken.Auth) {
t.Helper()
_, tokenString, err := token.Encode(map[string]any{"user_id": uint32(1)})
tokenString, err := token.Encode(map[string]any{"user_id": uint32(1)})
if err != nil {
t.Fatalf("failed to encode jwt: %v", err)
}
@@ -70,7 +70,7 @@ func setPublishingJwtCookie(t *testing.T, req *http.Request, token *jwtauth.JWTA
func TestArticlePublishingHttp(t *testing.T) {
validator.InitValidator()
token := jwtauth.New("HS256", []byte("test-secret"), nil)
token := authtoken.New([]byte("test-secret"))
t.Run("Publishing an offline article makes it online", func(t *testing.T) {
repo := newPublishingTestArticleRepo()
+6 -6
View File
@@ -17,11 +17,11 @@ import (
"strings"
"testing"
"RenewCMS/api/pkg/authtoken"
"github.com/go-chi/chi/v5"
"github.com/go-chi/jwtauth/v5"
)
func newArticleTitleRulesRouter(repo *mockArticleRepo, token *jwtauth.JWTAuth) *chi.Mux {
func newArticleTitleRulesRouter(repo *mockArticleRepo, token *authtoken.Auth) *chi.Mux {
userRepo := newVerifiedUserRepo()
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
@@ -37,8 +37,8 @@ func newArticleTitleRulesRouter(repo *mockArticleRepo, token *jwtauth.JWTAuth) *
r := chi.NewRouter()
r.Route("/v1", func(r chi.Router) {
r.Group(func(r chi.Router) {
r.Use(jwtauth.Verifier(token))
r.Use(jwtauth.Authenticator(token))
r.Use(authtoken.Verifier(token))
r.Use(authtoken.Authenticator)
r.Use(middleware.RequireVerifiedUser(getUserUseCase))
r.Route("/article", func(r chi.Router) {
r.Post("/", articleHandler.PostArticle)
@@ -48,7 +48,7 @@ func newArticleTitleRulesRouter(repo *mockArticleRepo, token *jwtauth.JWTAuth) *
return r
}
func postArticleRequest(t *testing.T, token *jwtauth.JWTAuth, title string, body string) *http.Request {
func postArticleRequest(t *testing.T, token *authtoken.Auth, title string, body string) *http.Request {
t.Helper()
payload, err := json.Marshal(map[string]string{"title": title, "body": body})
if err != nil {
@@ -122,7 +122,7 @@ func TestArticleTitleRunesInUseCase(t *testing.T) {
func TestArticleTitleRunesOverHttp(t *testing.T) {
validator.InitValidator()
token := jwtauth.New("HS256", []byte("test-secret"), nil)
token := authtoken.New([]byte("test-secret"))
cases := []struct {
length int
+4 -4
View File
@@ -9,13 +9,13 @@ import (
"net/http/httptest"
"testing"
"RenewCMS/api/pkg/authtoken"
"github.com/go-chi/chi/v5"
"github.com/go-chi/jwtauth/v5"
validator "RenewCMS/api/pkg/validator"
)
func newArticlesListRouter(articleRepo *mockArticleRepo, token *jwtauth.JWTAuth) *chi.Mux {
func newArticlesListRouter(articleRepo *mockArticleRepo, token *authtoken.Auth) *chi.Mux {
userRepo := newVerifiedUserRepo()
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
@@ -31,7 +31,7 @@ func newArticlesListRouter(articleRepo *mockArticleRepo, token *jwtauth.JWTAuth)
r := chi.NewRouter()
r.Route("/v1", func(r chi.Router) {
r.Route("/article", func(r chi.Router) {
r.Use(jwtauth.Verifier(token))
r.Use(authtoken.Verifier(token))
r.Get("/", articleHandler.ListArticles)
})
})
@@ -40,7 +40,7 @@ func newArticlesListRouter(articleRepo *mockArticleRepo, token *jwtauth.JWTAuth)
func TestArticlesListHttp(t *testing.T) {
validator.InitValidator()
token := jwtauth.New("HS256", []byte("test-secret"), nil)
token := authtoken.New([]byte("test-secret"))
t.Run("GET /v1/article/ items carry no images field now that images belong to the library", func(t *testing.T) {
repo := newMockArticleRepo()
+2 -2
View File
@@ -15,7 +15,7 @@ import (
"RenewCMS/internal/infrastructure/useCases"
"errors"
"github.com/go-chi/jwtauth/v5"
"RenewCMS/api/pkg/authtoken"
validator "RenewCMS/api/pkg/validator"
)
@@ -71,7 +71,7 @@ func itemIds(envelope paginationEnvelope) []int {
func TestArticlesPaginationApiHttp(t *testing.T) {
validator.InitValidator()
token := jwtauth.New("HS256", []byte("test-secret"), nil)
token := authtoken.New([]byte("test-secret"))
get := func(t *testing.T, repo *mockArticleRepo, query string, verified bool) (*httptest.ResponseRecorder, paginationEnvelope) {
t.Helper()
+162
View File
@@ -0,0 +1,162 @@
package test
import (
"RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/authtoken"
"net/http"
"net/http/httptest"
"testing"
)
type authTokenProbe struct {
reached bool
claims authtoken.Claims
err error
}
func authTokenRouter(auth *authtoken.Auth, probe *authTokenProbe, guarded bool) http.Handler {
final := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
probe.reached = true
probe.claims, probe.err = authtoken.FromContext(r.Context())
w.WriteHeader(http.StatusOK)
})
if guarded {
return authtoken.Verifier(auth)(authtoken.Authenticator(final))
}
return authtoken.Verifier(auth)(final)
}
func serveAuthToken(handler http.Handler, configure func(*http.Request)) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodGet, "/", nil)
configure(req)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
return rec
}
func TestAuthTokenVerifierAndAuthenticator(t *testing.T) {
auth := authtoken.New([]byte("test-secret"))
valid, _ := auth.Encode(map[string]any{"user_id": uint32(5)})
other, _ := auth.Encode(map[string]any{"user_id": uint32(9)})
t.Run("A valid jwt cookie reaches the handler with its claims", func(t *testing.T) {
probe := &authTokenProbe{}
rec := serveAuthToken(authTokenRouter(auth, probe, true), func(r *http.Request) {
r.AddCookie(&http.Cookie{Name: "jwt", Value: valid})
})
if rec.Code != http.StatusOK || probe.claims["user_id"] != float64(5) {
t.Errorf("expected 200 with user 5, got %d and %v", rec.Code, probe.claims)
}
})
t.Run("A bearer header in any letter case is accepted", func(t *testing.T) {
for _, scheme := range []string{"Bearer ", "BEARER ", "bearer "} {
probe := &authTokenProbe{}
rec := serveAuthToken(authTokenRouter(auth, probe, true), func(r *http.Request) {
r.Header.Set("Authorization", scheme+valid)
})
if rec.Code != http.StatusOK {
t.Errorf("scheme %q: expected 200, got %d", scheme, rec.Code)
}
}
})
t.Run("The bearer header wins over the cookie", func(t *testing.T) {
probe := &authTokenProbe{}
serveAuthToken(authTokenRouter(auth, probe, true), func(r *http.Request) {
r.Header.Set("Authorization", "Bearer "+valid)
r.AddCookie(&http.Cookie{Name: "jwt", Value: other})
})
if probe.claims["user_id"] != float64(5) {
t.Errorf("expected the header token, got %v", probe.claims)
}
})
t.Run("A malformed Authorization header falls back to the cookie", func(t *testing.T) {
for _, header := range []string{"Bearer", "Bearer ", "Basic abc", "Token " + valid} {
probe := &authTokenProbe{}
rec := serveAuthToken(authTokenRouter(auth, probe, true), func(r *http.Request) {
r.Header.Set("Authorization", header)
r.AddCookie(&http.Cookie{Name: "jwt", Value: valid})
})
if rec.Code != http.StatusOK {
t.Errorf("header %q: expected the cookie to be used, got %d", header, rec.Code)
}
}
})
t.Run("The authenticator answers 401 with the standard message when no token is sent", func(t *testing.T) {
probe := &authTokenProbe{}
rec := serveAuthToken(authTokenRouter(auth, probe, true), func(*http.Request) {})
if rec.Code != http.StatusUnauthorized || probe.reached {
t.Fatalf("expected a blocked 401, got %d reached=%v", rec.Code, probe.reached)
}
if got := rec.Body.String(); got != apperr.UnauthorizedErrorMessage+"\n" {
t.Errorf("unexpected body %q", got)
}
})
t.Run("The authenticator rejects a forged token", func(t *testing.T) {
probe := &authTokenProbe{}
forged, _ := authtoken.New([]byte("another-secret")).Encode(map[string]any{"user_id": 1})
rec := serveAuthToken(authTokenRouter(auth, probe, true), func(r *http.Request) {
r.AddCookie(&http.Cookie{Name: "jwt", Value: forged})
})
if rec.Code != http.StatusUnauthorized || probe.reached {
t.Errorf("expected a blocked 401, got %d reached=%v", rec.Code, probe.reached)
}
})
t.Run("Without the authenticator the handler sees the verification error", func(t *testing.T) {
probe := &authTokenProbe{}
serveAuthToken(authTokenRouter(auth, probe, false), func(r *http.Request) {
r.AddCookie(&http.Cookie{Name: "jwt", Value: "garbage"})
})
if !probe.reached || probe.err == nil || probe.claims != nil {
t.Errorf("expected an error and no claims, got %v / %v", probe.claims, probe.err)
}
})
t.Run("A context that never passed the verifier reports no token", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/", nil)
if _, err := authtoken.FromContext(req.Context()); err != authtoken.ErrNoToken {
t.Errorf("expected ErrNoToken, got %v", err)
}
})
t.Run("A context carrying nil claims and no error still reports no token", func(t *testing.T) {
ctx := authtoken.NewContext(httptest.NewRequest(http.MethodGet, "/", nil).Context(), nil, nil)
if _, err := authtoken.FromContext(ctx); err != authtoken.ErrNoToken {
t.Errorf("expected ErrNoToken, got %v", err)
}
})
t.Run("A context injected with claims is authenticated", func(t *testing.T) {
probe := &authTokenProbe{}
handler := authtoken.Authenticator(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { probe.reached = true }))
req := httptest.NewRequest(http.MethodGet, "/", nil)
req = req.WithContext(authtoken.NewContext(req.Context(), authtoken.Claims{"user_id": float64(1)}, nil))
handler.ServeHTTP(httptest.NewRecorder(), req)
if !probe.reached {
t.Error("expected the handler to be reached")
}
})
}
+181
View File
@@ -0,0 +1,181 @@
package test
import (
"RenewCMS/api/pkg/authtoken"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"errors"
"strconv"
"strings"
"testing"
"time"
)
var authTokenEpoch = time.Date(2026, time.March, 1, 12, 0, 0, 0, time.UTC)
func authTokenAt(now time.Time) *authtoken.Auth {
return authtoken.New([]byte("test-secret"), authtoken.WithClock(func() time.Time { return now }))
}
func signedAuthTokenSegments(secret string, header string, payload string) string {
encode := base64.RawURLEncoding.EncodeToString
signingInput := encode([]byte(header)) + "." + encode([]byte(payload))
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(signingInput))
return signingInput + "." + encode(mac.Sum(nil))
}
func TestAuthTokenEncodeDecode(t *testing.T) {
t.Run("A token round-trips its claims", func(t *testing.T) {
auth := authTokenAt(authTokenEpoch)
encoded, err := auth.Encode(map[string]any{"user_id": uint32(7), "token_version": uint32(3)})
if err != nil {
t.Fatalf("encode failed: %v", err)
}
claims, err := auth.Decode(encoded)
if err != nil {
t.Fatalf("decode failed: %v", err)
}
if claims["user_id"] != float64(7) || claims["token_version"] != float64(3) {
t.Errorf("unexpected claims %v", claims)
}
})
t.Run("Encoding stamps iat and an exp one session lifetime ahead", func(t *testing.T) {
auth := authTokenAt(authTokenEpoch)
encoded, _ := auth.Encode(map[string]any{"user_id": 1})
claims, _ := auth.Decode(encoded)
if claims["iat"] != float64(authTokenEpoch.Unix()) {
t.Errorf("expected iat %d, got %v", authTokenEpoch.Unix(), claims["iat"])
}
if claims["exp"] != float64(authTokenEpoch.Add(authtoken.SessionLifetime).Unix()) {
t.Errorf("unexpected exp %v", claims["exp"])
}
})
t.Run("Encoding keeps an explicit exp", func(t *testing.T) {
auth := authTokenAt(authTokenEpoch)
explicit := authTokenEpoch.Add(time.Minute).Unix()
encoded, _ := auth.Encode(map[string]any{"exp": explicit})
claims, _ := auth.Decode(encoded)
if claims["exp"] != float64(explicit) {
t.Errorf("expected exp %d, got %v", explicit, claims["exp"])
}
})
t.Run("Encoding does not mutate the caller's claims", func(t *testing.T) {
input := map[string]any{"user_id": 1}
_, _ = authTokenAt(authTokenEpoch).Encode(input)
if len(input) != 1 {
t.Errorf("input was mutated: %v", input)
}
})
t.Run("Encoding nil claims still yields a decodable token", func(t *testing.T) {
auth := authTokenAt(authTokenEpoch)
encoded, err := auth.Encode(nil)
if err != nil {
t.Fatalf("encode failed: %v", err)
}
if _, err := auth.Decode(encoded); err != nil {
t.Errorf("decode failed: %v", err)
}
})
t.Run("Encoding reports claims that cannot be serialized", func(t *testing.T) {
if _, err := authTokenAt(authTokenEpoch).Encode(map[string]any{"bad": make(chan int)}); err == nil {
t.Error("expected an error")
}
})
}
func TestAuthTokenDecodeRejections(t *testing.T) {
auth := authTokenAt(authTokenEpoch)
valid, _ := auth.Encode(map[string]any{"user_id": 1})
segments := strings.Split(valid, ".")
header := `{"alg":"HS256","typ":"JWT"}`
cases := map[string]struct {
token string
want error
}{
"empty": {"", authtoken.ErrNoToken},
"one segment": {"abc", authtoken.ErrInvalid},
"four segments": {valid + ".x", authtoken.ErrInvalid},
"not base64 header": {"!!!." + segments[1] + "." + segments[2], authtoken.ErrInvalid},
"signed with another secret": {signedAuthTokenSegments("other", header, `{"user_id":1}`), authtoken.ErrInvalid},
"alg none": {signedAuthTokenSegments("test-secret", `{"alg":"none"}`, `{"user_id":1}`), authtoken.ErrInvalid},
"alg HS512": {signedAuthTokenSegments("test-secret", `{"alg":"HS512"}`, `{"user_id":1}`), authtoken.ErrInvalid},
"alg missing": {signedAuthTokenSegments("test-secret", `{}`, `{"user_id":1}`), authtoken.ErrInvalid},
"tampered payload": {segments[0] + "." + base64.RawURLEncoding.EncodeToString([]byte(`{"user_id":2}`)) + "." + segments[2], authtoken.ErrInvalid},
"truncated signature": {segments[0] + "." + segments[1] + "." + segments[2][:10], authtoken.ErrInvalid},
"empty signature": {segments[0] + "." + segments[1] + ".", authtoken.ErrInvalid},
"payload not an object": {signedAuthTokenSegments("test-secret", header, `[1]`), authtoken.ErrInvalid},
"payload null": {signedAuthTokenSegments("test-secret", header, `null`), authtoken.ErrInvalid},
"exp not a number": {signedAuthTokenSegments("test-secret", header, `{"exp":"soon"}`), authtoken.ErrInvalid},
"nbf not a number": {signedAuthTokenSegments("test-secret", header, `{"nbf":"soon"}`), authtoken.ErrInvalid},
"expired": {signedAuthTokenSegments("test-secret", header, `{"exp":`+authTokenUnix(authTokenEpoch.Add(-time.Second).Unix())+`}`), authtoken.ErrExpired},
"expiring exactly now": {signedAuthTokenSegments("test-secret", header, `{"exp":`+authTokenUnix(authTokenEpoch.Unix())+`}`), authtoken.ErrExpired},
"not yet valid": {signedAuthTokenSegments("test-secret", header, `{"nbf":`+authTokenUnix(authTokenEpoch.Add(time.Second).Unix())+`}`), authtoken.ErrNotYetValid},
"larger than the size budget": {strings.Repeat("a", 5000), authtoken.ErrInvalid},
}
for name, tc := range cases {
t.Run("Rejects "+name, func(t *testing.T) {
claims, err := auth.Decode(tc.token)
if !errors.Is(err, tc.want) {
t.Errorf("expected %v, got %v", tc.want, err)
}
if claims != nil {
t.Errorf("expected no claims, got %v", claims)
}
})
}
t.Run("Accepts a token with no exp so sessions issued before expiry existed keep working", func(t *testing.T) {
legacy := signedAuthTokenSegments("test-secret", header, `{"user_id":4,"token_version":1}`)
claims, err := auth.Decode(legacy)
if err != nil || claims["user_id"] != float64(4) {
t.Errorf("expected legacy token accepted, got %v, %v", claims, err)
}
})
t.Run("Accepts a token whose nbf has passed", func(t *testing.T) {
token := signedAuthTokenSegments("test-secret", header, `{"nbf":`+authTokenUnix(authTokenEpoch.Add(-time.Second).Unix())+`}`)
if _, err := auth.Decode(token); err != nil {
t.Errorf("expected acceptance, got %v", err)
}
})
t.Run("A token expires once the clock passes its lifetime", func(t *testing.T) {
encoded, _ := auth.Encode(map[string]any{"user_id": 1})
justBefore := authTokenAt(authTokenEpoch.Add(authtoken.SessionLifetime - time.Second))
justAfter := authTokenAt(authTokenEpoch.Add(authtoken.SessionLifetime))
if _, err := justBefore.Decode(encoded); err != nil {
t.Errorf("expected valid before expiry, got %v", err)
}
if _, err := justAfter.Decode(encoded); !errors.Is(err, authtoken.ErrExpired) {
t.Errorf("expected expired, got %v", err)
}
})
}
func authTokenUnix(value int64) string {
return strconv.FormatInt(value, 10)
}
@@ -11,11 +11,11 @@ import (
"net/http/httptest"
"testing"
"RenewCMS/api/pkg/authtoken"
"github.com/go-chi/chi/v5"
"github.com/go-chi/jwtauth/v5"
)
func newCancelRegistrationRouter(userRepo *mockUserRepo, mailRepo *mockMailRepo, token *jwtauth.JWTAuth) *chi.Mux {
func newCancelRegistrationRouter(userRepo *mockUserRepo, mailRepo *mockMailRepo, token *authtoken.Auth) *chi.Mux {
hasher := &stubHasher{}
loginHandler := auth.NewLoginHandler(
token,
@@ -34,8 +34,8 @@ func newCancelRegistrationRouter(userRepo *mockUserRepo, mailRepo *mockMailRepo,
r.Route("/auth", func(r chi.Router) {
r.Post("/register", loginHandler.Register)
r.Group(func(r chi.Router) {
r.Use(jwtauth.Verifier(token))
r.Use(jwtauth.Authenticator(token))
r.Use(authtoken.Verifier(token))
r.Use(authtoken.Authenticator)
r.Post("/verify", verificationHandler.Verify)
r.Delete("/register", verificationHandler.CancelRegistration)
})
@@ -56,10 +56,10 @@ func newCancelRegistrationTestUserRepo(isVerified bool) *mockUserRepo {
return repo
}
func sendCancelRegistrationRequest(router *chi.Mux, token *jwtauth.JWTAuth, claims map[string]any) *httptest.ResponseRecorder {
func sendCancelRegistrationRequest(router *chi.Mux, token *authtoken.Auth, claims map[string]any) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodDelete, "/v1/auth/register", nil)
if claims != nil {
_, tokenString, _ := token.Encode(claims)
tokenString, _ := token.Encode(claims)
req.AddCookie(&http.Cookie{Name: "jwt", Value: tokenString})
}
rec := httptest.NewRecorder()
@@ -83,7 +83,7 @@ func jwtSetCookie(t *testing.T, rec *httptest.ResponseRecorder) (*http.Cookie, b
func TestCancelPendingRegistrationHttp(t *testing.T) {
validator.InitValidator()
token := jwtauth.New("HS256", []byte("test-secret"), nil)
token := authtoken.New([]byte("test-secret"))
t.Run("An unverified user cancels their pending registration", func(t *testing.T) {
repo := newCancelRegistrationTestUserRepo(false)
+6 -6
View File
@@ -7,12 +7,12 @@ import (
"net/http/httptest"
"testing"
"github.com/go-chi/jwtauth/v5"
"RenewCMS/api/pkg/authtoken"
)
func requestWithJwtClaims(t *testing.T, token *jwtauth.JWTAuth, claimsToEncode map[string]any) *http.Request {
func requestWithJwtClaims(t *testing.T, token *authtoken.Auth, claimsToEncode map[string]any) *http.Request {
t.Helper()
_, tokenString, err := token.Encode(claimsToEncode)
tokenString, err := token.Encode(claimsToEncode)
if err != nil {
t.Fatalf("failed to encode jwt: %v", err)
}
@@ -21,11 +21,11 @@ func requestWithJwtClaims(t *testing.T, token *jwtauth.JWTAuth, claimsToEncode m
t.Fatalf("failed to decode jwt: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/", nil)
return req.WithContext(jwtauth.NewContext(req.Context(), decoded, nil))
return req.WithContext(authtoken.NewContext(req.Context(), decoded, nil))
}
func TestClaimsTokenVersion(t *testing.T) {
token := jwtauth.New("HS256", []byte("test-secret"), nil)
token := authtoken.New([]byte("test-secret"))
t.Run("returns the numeric token_version claim", func(t *testing.T) {
req := requestWithJwtClaims(t, token, map[string]any{"user_id": uint32(7), "token_version": uint32(3)})
@@ -68,7 +68,7 @@ func TestClaimsTokenVersion(t *testing.T) {
}
func TestClaimsUserId(t *testing.T) {
token := jwtauth.New("HS256", []byte("test-secret"), nil)
token := authtoken.New([]byte("test-secret"))
t.Run("returns the numeric user_id claim", func(t *testing.T) {
req := requestWithJwtClaims(t, token, map[string]any{"user_id": uint32(7)})
+2 -2
View File
@@ -11,7 +11,7 @@ import (
"testing"
"time"
"github.com/go-chi/jwtauth/v5"
"RenewCMS/api/pkg/authtoken"
)
type containerHarness struct {
@@ -28,7 +28,7 @@ func newContainerHarness(t *testing.T, container bool, helperInstalled bool) *co
rig.helper.installed = helperInstalled
helper := infraupdate.NewContainerHelperDetector(rig.helper, container)
now := func() time.Time { return updateTestCheckedAt }
token := jwtauth.New("HS256", []byte("test-secret"), nil)
token := authtoken.New([]byte("test-secret"))
feed := &fakeReleaseFeed{release: update.Release{
Tag: "v2.3.0-beta",
URL: "https://git.example/fsylvain/RenewCMS/releases/tag/v2.3.0-beta",
+225
View File
@@ -0,0 +1,225 @@
package test
import (
"RenewCMS/api/middleware"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
type corsExchange struct {
rec *httptest.ResponseRecorder
reached bool
}
func corsRequest(origins []string, method string, headers map[string]string) corsExchange {
exchange := corsExchange{}
handler := middleware.CORS(origins)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
exchange.reached = true
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest(method, "/v1/article", nil)
for name, value := range headers {
req.Header.Set(name, value)
}
exchange.rec = httptest.NewRecorder()
handler.ServeHTTP(exchange.rec, req)
return exchange
}
func corsPreflight(origins []string, origin string, method string, requestHeaders string) corsExchange {
headers := map[string]string{"Origin": origin, "Access-Control-Request-Method": method}
if requestHeaders != "" {
headers["Access-Control-Request-Headers"] = requestHeaders
}
return corsRequest(origins, http.MethodOptions, headers)
}
func TestCORSActualRequests(t *testing.T) {
origins := []string{"https://app.example.com", "https://*.preview.example.com"}
t.Run("An allowed origin is echoed and the exposed headers are listed", func(t *testing.T) {
got := corsRequest(origins, http.MethodGet, map[string]string{"Origin": "https://app.example.com"})
if got.rec.Header().Get("Access-Control-Allow-Origin") != "https://app.example.com" {
t.Errorf("unexpected allow-origin %q", got.rec.Header().Get("Access-Control-Allow-Origin"))
}
if got.rec.Header().Get("Access-Control-Expose-Headers") != "Link, X-Total-Count" {
t.Errorf("unexpected expose-headers %q", got.rec.Header().Get("Access-Control-Expose-Headers"))
}
if !got.reached {
t.Error("the request must reach the handler")
}
})
t.Run("Credentials are never allowed", func(t *testing.T) {
got := corsRequest(origins, http.MethodGet, map[string]string{"Origin": "https://app.example.com"})
if got.rec.Header().Get("Access-Control-Allow-Credentials") != "" {
t.Error("Access-Control-Allow-Credentials must not be set")
}
})
t.Run("The match is case-insensitive", func(t *testing.T) {
got := corsRequest(origins, http.MethodGet, map[string]string{"Origin": "HTTPS://APP.EXAMPLE.COM"})
if got.rec.Header().Get("Access-Control-Allow-Origin") == "" {
t.Error("expected the origin to be allowed")
}
})
t.Run("A wildcard subdomain pattern matches", func(t *testing.T) {
got := corsRequest(origins, http.MethodGet, map[string]string{"Origin": "https://pr-12.preview.example.com"})
if got.rec.Header().Get("Access-Control-Allow-Origin") != "https://pr-12.preview.example.com" {
t.Errorf("unexpected allow-origin %q", got.rec.Header().Get("Access-Control-Allow-Origin"))
}
})
t.Run("A wildcard pattern does not match a look-alike", func(t *testing.T) {
for _, origin := range []string{"https://preview.example.com", "https://x.preview.example.com.evil.net", "http://x.preview.example.com"} {
got := corsRequest(origins, http.MethodGet, map[string]string{"Origin": origin})
if got.rec.Header().Get("Access-Control-Allow-Origin") != "" {
t.Errorf("origin %q must not be allowed", origin)
}
}
})
t.Run("An unknown origin gets no CORS headers but still reaches the handler", func(t *testing.T) {
got := corsRequest(origins, http.MethodGet, map[string]string{"Origin": "https://evil.example.net"})
if got.rec.Header().Get("Access-Control-Allow-Origin") != "" || !got.reached {
t.Errorf("unexpected exchange %v reached=%v", got.rec.Header(), got.reached)
}
})
t.Run("A request with no Origin is untouched", func(t *testing.T) {
got := corsRequest(origins, http.MethodGet, nil)
if got.rec.Header().Get("Vary") != "" || got.rec.Header().Get("Access-Control-Allow-Origin") != "" || !got.reached {
t.Errorf("unexpected headers %v", got.rec.Header())
}
})
t.Run("Responses vary on Origin so shared caches stay correct", func(t *testing.T) {
got := corsRequest(origins, http.MethodGet, map[string]string{"Origin": "https://evil.example.net"})
if got.rec.Header().Get("Vary") != "Origin" {
t.Errorf("unexpected Vary %q", got.rec.Header().Get("Vary"))
}
})
t.Run("A lone star allows any origin", func(t *testing.T) {
got := corsRequest([]string{"*"}, http.MethodGet, map[string]string{"Origin": "https://anything.example.net"})
if got.rec.Header().Get("Access-Control-Allow-Origin") != "https://anything.example.net" {
t.Errorf("unexpected allow-origin %q", got.rec.Header().Get("Access-Control-Allow-Origin"))
}
})
t.Run("Blank entries never match the empty origin", func(t *testing.T) {
got := corsRequest([]string{"", " "}, http.MethodGet, map[string]string{"Origin": "https://app.example.com"})
if got.rec.Header().Get("Access-Control-Allow-Origin") != "" {
t.Error("blank entries must be ignored")
}
})
}
func TestCORSPreflight(t *testing.T) {
origins := []string{"https://app.example.com"}
t.Run("An allowed preflight is answered 204 without reaching the handler", func(t *testing.T) {
got := corsPreflight(origins, "https://app.example.com", http.MethodPatch, "Content-Type, Authorization")
header := got.rec.Header()
if got.rec.Code != http.StatusNoContent || got.reached {
t.Fatalf("expected 204 and no handler, got %d reached=%v", got.rec.Code, got.reached)
}
if header.Get("Access-Control-Allow-Origin") != "https://app.example.com" {
t.Errorf("unexpected allow-origin %q", header.Get("Access-Control-Allow-Origin"))
}
if header.Get("Access-Control-Allow-Methods") != "PATCH" {
t.Errorf("unexpected allow-methods %q", header.Get("Access-Control-Allow-Methods"))
}
if header.Get("Access-Control-Max-Age") != "300" {
t.Errorf("unexpected max-age %q", header.Get("Access-Control-Max-Age"))
}
for _, allowed := range []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"} {
if !strings.Contains(header.Get("Access-Control-Allow-Headers"), allowed) {
t.Errorf("allow-headers misses %s: %q", allowed, header.Get("Access-Control-Allow-Headers"))
}
}
})
t.Run("Every supported method is allowed", func(t *testing.T) {
for _, method := range []string{"GET", "POST", "PUT", "PATCH", "DELETE"} {
got := corsPreflight(origins, "https://app.example.com", method, "")
if got.rec.Header().Get("Access-Control-Allow-Methods") != method {
t.Errorf("method %s not allowed", method)
}
}
})
t.Run("The requested method is matched case-insensitively", func(t *testing.T) {
got := corsPreflight(origins, "https://app.example.com", "patch", "")
if got.rec.Header().Get("Access-Control-Allow-Methods") != "PATCH" {
t.Errorf("unexpected allow-methods %q", got.rec.Header().Get("Access-Control-Allow-Methods"))
}
})
t.Run("A method outside the list is refused", func(t *testing.T) {
got := corsPreflight(origins, "https://app.example.com", "TRACE", "")
if got.rec.Header().Get("Access-Control-Allow-Origin") != "" || got.rec.Header().Get("Access-Control-Allow-Methods") != "" {
t.Errorf("unexpected headers %v", got.rec.Header())
}
})
t.Run("A header outside the list is refused", func(t *testing.T) {
got := corsPreflight(origins, "https://app.example.com", "GET", "Content-Type, X-Evil")
if got.rec.Header().Get("Access-Control-Allow-Origin") != "" {
t.Errorf("unexpected headers %v", got.rec.Header())
}
})
t.Run("Requested headers are matched case-insensitively and tolerate spacing", func(t *testing.T) {
got := corsPreflight(origins, "https://app.example.com", "GET", "content-type ,AUTHORIZATION,")
if got.rec.Header().Get("Access-Control-Allow-Origin") == "" {
t.Error("expected the preflight to be allowed")
}
})
t.Run("An unknown origin is refused", func(t *testing.T) {
got := corsPreflight(origins, "https://evil.example.net", "GET", "")
if got.rec.Header().Get("Access-Control-Allow-Origin") != "" || got.reached {
t.Errorf("unexpected exchange %v reached=%v", got.rec.Header(), got.reached)
}
})
t.Run("The preflight response varies on every request header it depends on", func(t *testing.T) {
got := corsPreflight(origins, "https://app.example.com", "GET", "")
vary := strings.Join(got.rec.Header().Values("Vary"), ",")
for _, field := range []string{"Origin", "Access-Control-Request-Method", "Access-Control-Request-Headers"} {
if !strings.Contains(vary, field) {
t.Errorf("Vary misses %s: %q", field, vary)
}
}
})
t.Run("An OPTIONS request without a requested method is treated as a normal request", func(t *testing.T) {
got := corsRequest(origins, http.MethodOptions, map[string]string{"Origin": "https://app.example.com"})
if !got.reached {
t.Error("expected the handler to be reached")
}
})
}
@@ -27,8 +27,8 @@ import (
"testing"
"time"
"RenewCMS/api/pkg/authtoken"
"github.com/go-chi/chi/v5"
"github.com/go-chi/jwtauth/v5"
)
const supportRecipeKey = "recipe"
@@ -377,7 +377,7 @@ func TestBuiltinTypeHTTP(t *testing.T) {
t.Run("The built-in routes are read-only and restricted", func(t *testing.T) {
env := newSupportEnv(t)
stale := func(req *http.Request) {
_, tokenString, _ := env.token.Encode(map[string]any{"user_id": uint32(ctStaleId), "token_version": 0})
tokenString, _ := env.token.Encode(map[string]any{"user_id": uint32(ctStaleId), "token_version": 0})
req.AddCookie(&http.Cookie{Name: "jwt", Value: tokenString})
}
apiKey := env.issueKey(ctAdminId)
@@ -414,7 +414,7 @@ func TestBuiltinTypeHTTP(t *testing.T) {
validator.InitValidator()
users := newMockUserRepo()
users.users[ctAdminId] = user.User{ID: ctAdminId, Username: "dev", IsVerified: true, Role: user.RoleAdmin}
token := jwtauth.New("HS256", []byte("test-secret"), nil)
token := authtoken.New([]byte("test-secret"))
now := func() time.Time { return time.Now() }
generator := hashing.NewSHA256KeyGenerator()
container := &api.UseCases{
@@ -428,7 +428,7 @@ func TestBuiltinTypeHTTP(t *testing.T) {
}
router := chi.NewRouter()
router.Mount("/v1", api.InitBackendRoutes(container, config.Settings{}))
_, tokenString, _ := token.Encode(map[string]any{"user_id": uint32(ctAdminId), "token_version": 0})
tokenString, _ := token.Encode(map[string]any{"user_id": uint32(ctAdminId), "token_version": 0})
for _, path := range []string{"/v1/builtin-type", "/v1/builtin-type/site"} {
req := httptest.NewRequest(http.MethodGet, path, nil)
req.AddCookie(&http.Cookie{Name: "jwt", Value: tokenString})
@@ -576,7 +576,7 @@ func TestUserLookupHTTP(t *testing.T) {
env := newSupportEnv(t)
apiKey := env.issueKey(ctAdminId)
stale := func(req *http.Request) {
_, tokenString, _ := env.token.Encode(map[string]any{"user_id": uint32(ctStaleId), "token_version": 0})
tokenString, _ := env.token.Encode(map[string]any{"user_id": uint32(ctStaleId), "token_version": 0})
req.AddCookie(&http.Cookie{Name: "jwt", Value: tokenString})
}
cases := []struct {
+4 -4
View File
@@ -19,8 +19,8 @@ import (
"testing"
"time"
"RenewCMS/api/pkg/authtoken"
"github.com/go-chi/chi/v5"
"github.com/go-chi/jwtauth/v5"
)
const (
@@ -238,7 +238,7 @@ type contentTypeEnv struct {
users *mockUserRepo
keys *mockAPIKeyRepo
generator *hashing.SHA256KeyGenerator
token *jwtauth.JWTAuth
token *authtoken.Auth
clock time.Time
router http.Handler
}
@@ -254,7 +254,7 @@ func newContentTypeEnv(t *testing.T) *contentTypeEnv {
users: newMockUserRepo(),
keys: newMockAPIKeyRepo(),
generator: hashing.NewSHA256KeyGenerator(),
token: jwtauth.New("HS256", []byte("test-secret"), nil),
token: authtoken.New([]byte("test-secret")),
clock: time.Date(2026, time.October, 5, 9, 0, 0, 0, time.UTC),
}
env.users.users[ctOwnerId] = user.User{ID: ctOwnerId, Username: "marta", IsVerified: true, Role: user.RoleOwner}
@@ -313,7 +313,7 @@ func (e *contentTypeEnv) asUser(t *testing.T, userId uint32) func(*http.Request)
if userId == 0 {
return
}
_, tokenString, err := e.token.Encode(map[string]any{"user_id": userId, "token_version": 0})
tokenString, err := e.token.Encode(map[string]any{"user_id": userId, "token_version": 0})
if err != nil {
t.Fatalf("failed to encode jwt: %v", err)
}
+157
View File
@@ -0,0 +1,157 @@
package test
import (
"RenewCMS/internal/infrastructure/config"
"errors"
"io/fs"
"os"
"path/filepath"
"strings"
"testing"
)
func TestParseDotenv(t *testing.T) {
cases := map[string]struct {
input string
want map[string]string
}{
"plain assignment": {"A=1", map[string]string{"A": "1"}},
"spaces around the equals sign": {"A = 1", map[string]string{"A": "1"}},
"export prefix": {"export A=1", map[string]string{"A": "1"}},
"empty value": {"A=", map[string]string{"A": ""}},
"blank and comment lines": {"\n# note\n \nA=1\n # indented\n", map[string]string{"A": "1"}},
"inline comment on an unquoted value": {"A=value # trailing", map[string]string{"A": "value"}},
"hash without a leading space": {"A=pass#word", map[string]string{"A": "pass#word"}},
"equals sign inside the value": {"A=b=c", map[string]string{"A": "b=c"}},
"URL value": {"A=http://localhost:5173/x?y=1", map[string]string{"A": "http://localhost:5173/x?y=1"}},
"double quoted keeps spaces and hash": {`A=" a # b "`, map[string]string{"A": " a # b "}},
"double quoted escapes": {`A="l1\nl2\t\"q\" \\ \x"`, map[string]string{"A": "l1\nl2\t\"q\" \\ \\x"}},
"double quoted then comment": {`A="v" # note`, map[string]string{"A": "v"}},
"single quoted is literal": {`A='a\nb # c'`, map[string]string{"A": `a\nb # c`}},
"dollar signs are not expanded": {"A=pa$$word${B}", map[string]string{"A": "pa$$word${B}"}},
"dotted names": {"a.b_c1=2", map[string]string{"a.b_c1": "2"}},
"windows line endings": {"A=1\r\nB=2\r\n", map[string]string{"A": "1", "B": "2"}},
"byte order mark": {"\xef\xbb\xbfA=1", map[string]string{"A": "1"}},
"last duplicate wins": {"A=1\nA=2", map[string]string{"A": "2"}},
"no trailing newline": {"A=1\nB=2", map[string]string{"A": "1", "B": "2"}},
}
for name, tc := range cases {
t.Run("Parses "+name, func(t *testing.T) {
got, err := config.ParseDotenv(strings.NewReader(tc.input))
if err != nil {
t.Fatalf("unexpected error %v", err)
}
if len(got) != len(tc.want) {
t.Fatalf("expected %v, got %v", tc.want, got)
}
for key, value := range tc.want {
if got[key] != value {
t.Errorf("%s: expected %q, got %q", key, value, got[key])
}
}
})
}
rejected := map[string]string{
"a line with no equals sign": "JUST_A_WORD",
"an empty name": "=value",
"a name with a space": "MY VAR=1",
"a name with a dash": "MY-VAR=1",
"an unterminated double quote": `A="open`,
"a trailing backslash": `A="open\`,
"an unterminated single quote": `A='open`,
}
for name, input := range rejected {
t.Run("Rejects "+name+" and names the line", func(t *testing.T) {
_, err := config.ParseDotenv(strings.NewReader("OK=1\n" + input))
if err == nil || !strings.Contains(err.Error(), "line 2") {
t.Errorf("expected a line 2 error, got %v", err)
}
})
}
t.Run("Rejects a line longer than the budget", func(t *testing.T) {
_, err := config.ParseDotenv(strings.NewReader("A=" + strings.Repeat("x", 2<<20)))
if err == nil {
t.Error("expected an error")
}
})
}
func TestLoadDotenv(t *testing.T) {
write := func(t *testing.T, content string) string {
t.Helper()
path := filepath.Join(t.TempDir(), ".env")
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatal(err)
}
return path
}
t.Run("Sets variables that are not already set", func(t *testing.T) {
t.Setenv("DOTENV_TEST_NEW", "")
_ = os.Unsetenv("DOTENV_TEST_NEW")
err := config.LoadDotenv(write(t, "DOTENV_TEST_NEW=from-file"))
if err != nil || os.Getenv("DOTENV_TEST_NEW") != "from-file" {
t.Errorf("expected from-file, got %q (%v)", os.Getenv("DOTENV_TEST_NEW"), err)
}
_ = os.Unsetenv("DOTENV_TEST_NEW")
})
t.Run("Never overrides a variable already in the environment", func(t *testing.T) {
t.Setenv("DOTENV_TEST_KEEP", "from-env")
_ = config.LoadDotenv(write(t, "DOTENV_TEST_KEEP=from-file"))
if os.Getenv("DOTENV_TEST_KEEP") != "from-env" {
t.Errorf("environment was overridden: %q", os.Getenv("DOTENV_TEST_KEEP"))
}
})
t.Run("Keeps an explicitly empty environment variable", func(t *testing.T) {
t.Setenv("DOTENV_TEST_EMPTY", "")
_ = config.LoadDotenv(write(t, "DOTENV_TEST_EMPTY=from-file"))
if value, set := os.LookupEnv("DOTENV_TEST_EMPTY"); !set || value != "" {
t.Errorf("expected an empty value, got %q set=%v", value, set)
}
})
t.Run("Applies nothing when any line is malformed", func(t *testing.T) {
_ = os.Unsetenv("DOTENV_TEST_ATOMIC")
err := config.LoadDotenv(write(t, "DOTENV_TEST_ATOMIC=1\nbroken line"))
if err == nil {
t.Fatal("expected an error")
}
if _, set := os.LookupEnv("DOTENV_TEST_ATOMIC"); set {
t.Error("a partially applied file must not leak variables")
}
})
t.Run("Names the file in a parse error", func(t *testing.T) {
path := write(t, "broken line")
err := config.LoadDotenv(path)
if err == nil || !strings.Contains(err.Error(), path) {
t.Errorf("expected the path in the error, got %v", err)
}
})
t.Run("Reports a missing file as fs.ErrNotExist", func(t *testing.T) {
err := config.LoadDotenv(filepath.Join(t.TempDir(), "absent.env"))
if !errors.Is(err, fs.ErrNotExist) {
t.Errorf("expected fs.ErrNotExist, got %v", err)
}
})
}
+7 -7
View File
@@ -15,11 +15,11 @@ import (
"strings"
"testing"
"RenewCMS/api/pkg/authtoken"
"github.com/go-chi/chi/v5"
"github.com/go-chi/jwtauth/v5"
)
func newEditArticleBodyRouter(articleRepo *mockArticleRepo, token *jwtauth.JWTAuth) *chi.Mux {
func newEditArticleBodyRouter(articleRepo *mockArticleRepo, token *authtoken.Auth) *chi.Mux {
userRepo := newVerifiedUserRepo()
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
@@ -36,9 +36,9 @@ func newEditArticleBodyRouter(articleRepo *mockArticleRepo, token *jwtauth.JWTAu
r.Route("/v1", func(r chi.Router) {
r.Route("/article", func(r chi.Router) {
r.Group(func(r chi.Router) {
r.Use(jwtauth.Verifier(token))
r.Use(authtoken.Verifier(token))
r.Group(func(r chi.Router) {
r.Use(jwtauth.Authenticator(token))
r.Use(authtoken.Authenticator)
r.Use(middleware.RequireVerifiedUser(getUserUseCase))
r.Patch("/{id}", articleHandler.UpdateArticleBody)
})
@@ -55,9 +55,9 @@ func newEditArticleBodyTestRepo() *mockArticleRepo {
return repo
}
func setEditArticleBodyJwtCookie(t *testing.T, req *http.Request, token *jwtauth.JWTAuth) {
func setEditArticleBodyJwtCookie(t *testing.T, req *http.Request, token *authtoken.Auth) {
t.Helper()
_, tokenString, err := token.Encode(map[string]any{"user_id": uint32(1)})
tokenString, err := token.Encode(map[string]any{"user_id": uint32(1)})
if err != nil {
t.Fatalf("failed to encode jwt: %v", err)
}
@@ -70,7 +70,7 @@ func newEditArticleBodyRequest(method, target string, body string) *http.Request
func TestEditArticleBodyHttp(t *testing.T) {
validator.InitValidator()
token := jwtauth.New("HS256", []byte("test-secret"), nil)
token := authtoken.New([]byte("test-secret"))
t.Run("Editing an existing article's body updates only the body", func(t *testing.T) {
repo := newEditArticleBodyTestRepo()
+12 -12
View File
@@ -13,11 +13,11 @@ import (
"net/http/httptest"
"testing"
"RenewCMS/api/pkg/authtoken"
"github.com/go-chi/chi/v5"
"github.com/go-chi/jwtauth/v5"
)
func newGetCurrentUserRouter(userRepo *mockUserRepo, hasher *stubHasher, token *jwtauth.JWTAuth) *chi.Mux {
func newGetCurrentUserRouter(userRepo *mockUserRepo, hasher *stubHasher, token *authtoken.Auth) *chi.Mux {
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
meHandler := auth.NewMeHandler(getUserUseCase, useCases.NewGetMyAuthorUseCase(plainAuthors()))
verificationHandler := auth.NewVerificationHandler(
@@ -29,8 +29,8 @@ func newGetCurrentUserRouter(userRepo *mockUserRepo, hasher *stubHasher, token *
r := chi.NewRouter()
r.Route("/v1/auth", func(r chi.Router) {
r.Group(func(r chi.Router) {
r.Use(jwtauth.Verifier(token))
r.Use(jwtauth.Authenticator(token))
r.Use(authtoken.Verifier(token))
r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(getUserUseCase))
r.Get("/me", meHandler.Me)
r.Delete("/register", verificationHandler.CancelRegistration)
@@ -39,9 +39,9 @@ func newGetCurrentUserRouter(userRepo *mockUserRepo, hasher *stubHasher, token *
return r
}
func mintGetCurrentUserCookie(t *testing.T, token *jwtauth.JWTAuth, userId uint32, tokenVersion uint32) *http.Cookie {
func mintGetCurrentUserCookie(t *testing.T, token *authtoken.Auth, userId uint32, tokenVersion uint32) *http.Cookie {
t.Helper()
_, tokenString, err := token.Encode(map[string]any{"user_id": userId, "token_version": tokenVersion})
tokenString, err := token.Encode(map[string]any{"user_id": userId, "token_version": tokenVersion})
if err != nil {
t.Fatalf("failed to encode jwt: %v", err)
}
@@ -62,7 +62,7 @@ func TestGetCurrentUserHttp(t *testing.T) {
validator.InitValidator()
t.Run("A verified user fetches their own profile", func(t *testing.T) {
token := jwtauth.New("HS256", []byte("test-secret"), nil)
token := authtoken.New([]byte("test-secret"))
userRepo := newMockUserRepo()
userRepo.users[7] = user.User{
ID: 7,
@@ -92,7 +92,7 @@ func TestGetCurrentUserHttp(t *testing.T) {
})
t.Run("An unverified user still fetches their own profile, with is_verified false, not a 403", func(t *testing.T) {
token := jwtauth.New("HS256", []byte("test-secret"), nil)
token := authtoken.New([]byte("test-secret"))
userRepo := newMockUserRepo()
userRepo.users[8] = user.User{
ID: 8,
@@ -122,7 +122,7 @@ func TestGetCurrentUserHttp(t *testing.T) {
})
t.Run("No jwt cookie at all is refused", func(t *testing.T) {
token := jwtauth.New("HS256", []byte("test-secret"), nil)
token := authtoken.New([]byte("test-secret"))
userRepo := newMockUserRepo()
userRepo.users[7] = user.User{ID: 7, Username: "alice", IsVerified: true, TokenVersion: 0}
router := newGetCurrentUserRouter(userRepo, &stubHasher{}, token)
@@ -135,7 +135,7 @@ func TestGetCurrentUserHttp(t *testing.T) {
})
t.Run("A jwt whose token_version no longer matches the stored user is refused", func(t *testing.T) {
token := jwtauth.New("HS256", []byte("test-secret"), nil)
token := authtoken.New([]byte("test-secret"))
userRepo := newMockUserRepo()
userRepo.users[7] = user.User{ID: 7, Username: "alice", IsVerified: true, TokenVersion: 1}
router := newGetCurrentUserRouter(userRepo, &stubHasher{}, token)
@@ -152,7 +152,7 @@ func TestGetCurrentUserHttp(t *testing.T) {
})
t.Run("A jwt minted for a since-deleted account is refused", func(t *testing.T) {
token := jwtauth.New("HS256", []byte("test-secret"), nil)
token := authtoken.New([]byte("test-secret"))
userRepo := newMockUserRepo()
userRepo.users[7] = user.User{ID: 7, Username: "alice", IsVerified: false, TokenVersion: 0}
router := newGetCurrentUserRouter(userRepo, &stubHasher{}, token)
@@ -173,7 +173,7 @@ func TestGetCurrentUserHttp(t *testing.T) {
})
t.Run("The route is never rate-limited, unlike login and password reset", func(t *testing.T) {
token := jwtauth.New("HS256", []byte("test-secret"), nil)
token := authtoken.New([]byte("test-secret"))
userRepo := newMockUserRepo()
userRepo.users[7] = user.User{ID: 7, Username: "alice", IsVerified: true, TokenVersion: 0}
router := newGetCurrentUserRouter(userRepo, &stubHasher{}, token)
+3 -3
View File
@@ -15,8 +15,8 @@ import (
"strings"
"testing"
"RenewCMS/api/pkg/authtoken"
"github.com/go-chi/chi/v5"
"github.com/go-chi/jwtauth/v5"
)
var errStubHashFailure = errors.New("stub hash failure")
@@ -153,7 +153,7 @@ func TestBcryptHasherHashAndCompare(t *testing.T) {
}
func newHasherRouter(userRepo *mockUserRepo, hasher *stubHasher) *chi.Mux {
token := jwtauth.New("HS256", []byte("test-secret"), nil)
token := authtoken.New([]byte("test-secret"))
loginHandler := auth.NewLoginHandler(
token,
useCases.NewCreateUserUseCase(userRepo, hasher, newMockMailRepo(), "https://cms.example.com", discardRecorder),
@@ -170,7 +170,7 @@ func newHasherRouter(userRepo *mockUserRepo, hasher *stubHasher) *chi.Mux {
}
func newRealHasherRouter(userRepo *mockUserRepo) *chi.Mux {
token := jwtauth.New("HS256", []byte("test-secret"), nil)
token := authtoken.New([]byte("test-secret"))
hasher := hashing.NewBcryptHasher()
loginHandler := auth.NewLoginHandler(
token,
+4 -4
View File
@@ -20,9 +20,9 @@ import (
"testing"
"time"
"RenewCMS/api/pkg/authtoken"
"github.com/go-chi/chi/v5"
chimiddleware "github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/jwtauth/v5"
)
const (
@@ -49,7 +49,7 @@ type variantEnv struct {
articles *mockArticleRepo
keys *mockAPIKeyRepo
logs *bytes.Buffer
token *jwtauth.JWTAuth
token *authtoken.Auth
now time.Time
usage *mockVariantUsageRepo
tracker *usage.Tracker
@@ -82,7 +82,7 @@ func newVariantEnvLimits(t *testing.T, renderer *countingRenderer, limits useCas
articles: newMockArticleRepo(),
keys: newMockAPIKeyRepo(),
logs: &bytes.Buffer{},
token: jwtauth.New("HS256", []byte("test-secret"), nil),
token: authtoken.New([]byte("test-secret")),
now: time.Date(2026, time.October, 8, 9, 0, 0, 0, time.UTC),
usage: newMockVariantUsageRepo(),
}
@@ -152,7 +152,7 @@ func (e *variantEnv) issueAPIKey() string {
func (e *variantEnv) cookie(userId uint32, tokenVersion uint32) *http.Cookie {
e.t.Helper()
_, signed, err := e.token.Encode(map[string]any{"user_id": userId, "token_version": tokenVersion})
signed, err := e.token.Encode(map[string]any{"user_id": userId, "token_version": tokenVersion})
if err != nil {
e.t.Fatalf("failed to encode jwt: %v", err)
}
+6 -6
View File
@@ -21,8 +21,8 @@ import (
"testing"
"time"
"RenewCMS/api/pkg/authtoken"
"github.com/go-chi/chi/v5"
"github.com/go-chi/jwtauth/v5"
)
const invitePublicURL = "https://cms.example.com"
@@ -137,7 +137,7 @@ type inviteFixture struct {
mail *mockMailRepo
hasher *stubHasher
router *chi.Mux
token *jwtauth.JWTAuth
token *authtoken.Auth
}
func newInviteFixture(t *testing.T, maxAttempts int) *inviteFixture {
@@ -148,7 +148,7 @@ func newInviteFixture(t *testing.T, maxAttempts int) *inviteFixture {
invites: newInviteFakeRepo(),
mail: newMockMailRepo(),
hasher: &stubHasher{},
token: jwtauth.New("HS256", []byte("test-secret"), nil),
token: authtoken.New([]byte("test-secret")),
}
getUser := useCases.NewGetUserUseCase(f.users)
handler := userhandler.NewInviteHandler(
@@ -159,8 +159,8 @@ func newInviteFixture(t *testing.T, maxAttempts int) *inviteFixture {
)
f.router = chi.NewRouter()
f.router.Route("/v1/user", func(r chi.Router) {
r.Use(jwtauth.Verifier(f.token))
r.Use(jwtauth.Authenticator(f.token))
r.Use(authtoken.Verifier(f.token))
r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(getUser))
r.Use(middleware.RequireVerifiedUser(getUser))
r.Get("/", handler.ListUsers)
@@ -535,7 +535,7 @@ func TestInvitationsDoNotChangeRegistration(t *testing.T) {
newRegistration := func(users *mockUserRepo, mail *mockMailRepo) *chi.Mux {
hasher := &stubHasher{}
handler := auth.NewLoginHandler(
jwtauth.New("HS256", []byte("test-secret"), nil),
authtoken.New([]byte("test-secret")),
useCases.NewCreateUserUseCase(users, hasher, mail, invitePublicURL, discardRecorder),
useCases.NewAuthenticateUserUseCase(users, hasher, discardRecorder),
true,
+5 -5
View File
@@ -19,9 +19,9 @@ import (
"testing"
"time"
"RenewCMS/api/pkg/authtoken"
"github.com/glebarez/sqlite"
"github.com/go-chi/chi/v5"
"github.com/go-chi/jwtauth/v5"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
@@ -34,7 +34,7 @@ type inviteManagementFixture struct {
mail *mockMailRepo
hasher *stubHasher
router *chi.Mux
token *jwtauth.JWTAuth
token *authtoken.Auth
}
func newInviteManagementFixture(t *testing.T, maxAttempts int) *inviteManagementFixture {
@@ -45,7 +45,7 @@ func newInviteManagementFixture(t *testing.T, maxAttempts int) *inviteManagement
invites: newInviteFakeRepo(),
mail: newMockMailRepo(),
hasher: &stubHasher{},
token: jwtauth.New("HS256", []byte("test-secret"), nil),
token: authtoken.New([]byte("test-secret")),
}
f.invites.users = f.users
f.users.users[9] = user.User{ID: 9, Username: "jonas", Email: "jonas@renew.example", IsVerified: true, Role: user.RoleAdmin}
@@ -71,8 +71,8 @@ func newInviteManagementFixture(t *testing.T, maxAttempts int) *inviteManagement
f.router.Get("/v1/auth/invite", acceptHandler.GetInvite)
f.router.Post("/v1/auth/invite/accept", acceptHandler.AcceptInvite)
f.router.Route("/v1/user", func(r chi.Router) {
r.Use(jwtauth.Verifier(f.token))
r.Use(jwtauth.Authenticator(f.token))
r.Use(authtoken.Verifier(f.token))
r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(getUser))
r.Use(middleware.RequireVerifiedUser(getUser))
r.Get("/", handler.ListUsers)
@@ -15,9 +15,9 @@ import (
"testing"
"time"
"RenewCMS/api/pkg/authtoken"
"github.com/go-chi/chi/v5"
chimiddleware "github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/jwtauth/v5"
)
type countingAuthenticateUserRepo struct {
@@ -33,7 +33,7 @@ func (r *countingAuthenticateUserRepo) GetByUsername(username string) (user.User
func newRateLimitedAuthRouter(t *testing.T, userRepo user.Repository, mailRepo *mockMailRepo, maxAttempts int, window time.Duration) *chi.Mux {
t.Helper()
hasher := hashing.NewBcryptHasher()
token := jwtauth.New("HS256", []byte("test-secret"), nil)
token := authtoken.New([]byte("test-secret"))
loginHandler := auth.NewLoginHandler(
token,
useCases.NewCreateUserUseCase(userRepo, hasher, mailRepo, "https://cms.example.com", discardRecorder),
+3 -3
View File
@@ -11,11 +11,11 @@ import (
"testing"
"time"
"RenewCMS/api/pkg/authtoken"
"github.com/go-chi/chi/v5"
"github.com/go-chi/jwtauth/v5"
)
func newLoginLogoutCookieRouter(userRepo *mockUserRepo, mailRepo *mockMailRepo, token *jwtauth.JWTAuth) *chi.Mux {
func newLoginLogoutCookieRouter(userRepo *mockUserRepo, mailRepo *mockMailRepo, token *authtoken.Auth) *chi.Mux {
hasher := &stubHasher{}
loginHandler := auth.NewLoginHandler(
token,
@@ -32,7 +32,7 @@ func newLoginLogoutCookieRouter(userRepo *mockUserRepo, mailRepo *mockMailRepo,
func TestLoginSetsA24HourJwtCookieAndLogoutClearsIt(t *testing.T) {
validator.InitValidator()
token := jwtauth.New("HS256", []byte("test-secret"), nil)
token := authtoken.New([]byte("test-secret"))
t.Run("Login sets a jwt cookie that expires about 24 hours from now", func(t *testing.T) {
repo := newMockUserRepo()
+4 -4
View File
@@ -18,8 +18,8 @@ import (
"testing"
"time"
"RenewCMS/api/pkg/authtoken"
"github.com/go-chi/chi/v5"
"github.com/go-chi/jwtauth/v5"
)
const (
@@ -38,7 +38,7 @@ type mediaEnv struct {
purgeSettings *mockPurgeSettingsRepo
clock time.Time
logs *bytes.Buffer
token *jwtauth.JWTAuth
token *authtoken.Auth
router http.Handler
}
@@ -52,7 +52,7 @@ func newMediaEnv(t *testing.T) *mediaEnv {
variants: newMockVariantFileRepo(),
storage: newMockImageStorage(),
users: newMockUserRepo(),
token: jwtauth.New("HS256", []byte("test-secret"), nil),
token: authtoken.New([]byte("test-secret")),
purgeSettings: newMockPurgeSettingsRepo(),
clock: time.Date(2026, time.October, 5, 9, 0, 0, 0, time.UTC),
logs: &bytes.Buffer{},
@@ -93,7 +93,7 @@ func newMediaEnv(t *testing.T) *mediaEnv {
func (e *mediaEnv) cookie(t *testing.T, userId uint32, tokenVersion uint32) *http.Cookie {
t.Helper()
_, tokenString, err := e.token.Encode(map[string]any{"user_id": userId, "token_version": tokenVersion})
tokenString, err := e.token.Encode(map[string]any{"user_id": userId, "token_version": tokenVersion})
if err != nil {
t.Fatalf("failed to encode jwt: %v", err)
}
+4 -4
View File
@@ -30,8 +30,8 @@ import (
"testing"
"time"
"RenewCMS/api/pkg/authtoken"
"github.com/go-chi/chi/v5"
"github.com/go-chi/jwtauth/v5"
)
const (
@@ -71,7 +71,7 @@ type hardeningEnv struct {
settings *mockPurgeSettingsRepo
logs *bytes.Buffer
users *mockUserRepo
token *jwtauth.JWTAuth
token *authtoken.Auth
router http.Handler
}
@@ -90,7 +90,7 @@ func newHardeningEnv(t *testing.T) *hardeningEnv {
settings: newMockPurgeSettingsRepo(),
logs: &bytes.Buffer{},
users: newMockUserRepo(),
token: jwtauth.New("HS256", []byte("test-secret"), nil),
token: authtoken.New([]byte("test-secret")),
}
env.users.users[hardeningAdminID] = user.User{ID: hardeningAdminID, Username: "marta", IsVerified: true, Role: user.RoleAdmin}
env.transactor = newMergeTransactor(env.images, env.articles, env.sites, env.authors, env.entries)
@@ -124,7 +124,7 @@ func (e *hardeningEnv) send(t *testing.T, method string, path string, body strin
if body != "" {
req.Header.Set("Content-Type", "application/json")
}
_, tokenString, err := e.token.Encode(map[string]any{"user_id": hardeningAdminID, "token_version": 0})
tokenString, err := e.token.Encode(map[string]any{"user_id": hardeningAdminID, "token_version": 0})
if err != nil {
t.Fatalf("failed to encode jwt: %v", err)
}
+6 -6
View File
@@ -18,9 +18,9 @@ import (
"net/http/httptest"
"testing"
"RenewCMS/api/pkg/authtoken"
"github.com/glebarez/sqlite"
"github.com/go-chi/chi/v5"
"github.com/go-chi/jwtauth/v5"
"gorm.io/gorm"
)
@@ -123,7 +123,7 @@ func TestUserRepositoryReportsMissingUserAsDomainError(t *testing.T) {
})
}
func newNotFoundErrorsRouter(articleRepo *mockArticleRepo, userRepo *mockUserRepo, token *jwtauth.JWTAuth) *chi.Mux {
func newNotFoundErrorsRouter(articleRepo *mockArticleRepo, userRepo *mockUserRepo, token *authtoken.Auth) *chi.Mux {
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
articleHandler := article.NewHandler(
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
@@ -144,7 +144,7 @@ func newNotFoundErrorsRouter(articleRepo *mockArticleRepo, userRepo *mockUserRep
r := chi.NewRouter()
r.Route("/v1", func(r chi.Router) {
r.Route("/article", func(r chi.Router) {
r.Use(jwtauth.Verifier(token))
r.Use(authtoken.Verifier(token))
r.Get("/{id}", articleHandler.GetArticle)
})
r.Route("/auth", func(r chi.Router) {
@@ -154,9 +154,9 @@ func newNotFoundErrorsRouter(articleRepo *mockArticleRepo, userRepo *mockUserRep
return r
}
func setJwtCookie(t *testing.T, req *http.Request, token *jwtauth.JWTAuth, userId uint32) {
func setJwtCookie(t *testing.T, req *http.Request, token *authtoken.Auth, userId uint32) {
t.Helper()
_, tokenString, err := token.Encode(map[string]any{"user_id": userId})
tokenString, err := token.Encode(map[string]any{"user_id": userId})
if err != nil {
t.Fatalf("failed to encode jwt: %v", err)
}
@@ -165,7 +165,7 @@ func setJwtCookie(t *testing.T, req *http.Request, token *jwtauth.JWTAuth, userI
func TestNotFoundErrorsHttp(t *testing.T) {
validator.InitValidator()
token := jwtauth.New("HS256", []byte("test-secret"), nil)
token := authtoken.New([]byte("test-secret"))
newArticleRepo := func() *mockArticleRepo {
repo := newMockArticleRepo()
+5 -5
View File
@@ -14,8 +14,8 @@ import (
"strings"
"testing"
"RenewCMS/api/pkg/authtoken"
"github.com/go-chi/chi/v5"
"github.com/go-chi/jwtauth/v5"
validator "RenewCMS/api/pkg/validator"
)
@@ -37,7 +37,7 @@ func (w *erroringResponseWriter) Write([]byte) (int, error) {
func (w *erroringResponseWriter) WriteHeader(int) {}
func newPublicArticleListingRouter(articleRepo *mockArticleRepo, token *jwtauth.JWTAuth) *chi.Mux {
func newPublicArticleListingRouter(articleRepo *mockArticleRepo, token *authtoken.Auth) *chi.Mux {
userRepo := newVerifiedUserRepo()
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
@@ -53,13 +53,13 @@ func newPublicArticleListingRouter(articleRepo *mockArticleRepo, token *jwtauth.
r := chi.NewRouter()
r.Route("/v1", func(r chi.Router) {
r.Route("/article", func(r chi.Router) {
r.Use(jwtauth.Verifier(token))
r.Use(authtoken.Verifier(token))
r.Group(func(r chi.Router) {
r.Get("/", articleHandler.ListArticles)
r.Get("/{id}", articleHandler.GetArticle)
})
r.Group(func(r chi.Router) {
r.Use(jwtauth.Authenticator(token))
r.Use(authtoken.Authenticator)
r.Use(middleware.RequireVerifiedUser(getUserUseCase))
r.Post("/", articleHandler.PostArticle)
r.Delete("/{id}", articleHandler.DeleteArticle)
@@ -78,7 +78,7 @@ func seedPublicArticleListingRepo() *mockArticleRepo {
func TestPublicArticleListingHttp(t *testing.T) {
validator.InitValidator()
token := jwtauth.New("HS256", []byte("test-secret"), nil)
token := authtoken.New([]byte("test-secret"))
t.Run("An anonymous visitor only sees online articles", func(t *testing.T) {
router := newPublicArticleListingRouter(seedPublicArticleListingRepo(), token)
+7 -7
View File
@@ -17,11 +17,11 @@ import (
"strings"
"testing"
"RenewCMS/api/pkg/authtoken"
"github.com/go-chi/chi/v5"
"github.com/go-chi/jwtauth/v5"
)
func newPublicArticleReadRouter(articleRepo *mockArticleRepo, userRepo *mockUserRepo, token *jwtauth.JWTAuth) *chi.Mux {
func newPublicArticleReadRouter(articleRepo *mockArticleRepo, userRepo *mockUserRepo, token *authtoken.Auth) *chi.Mux {
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
articleHandler := article.NewHandler(
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
@@ -35,11 +35,11 @@ func newPublicArticleReadRouter(articleRepo *mockArticleRepo, userRepo *mockUser
r := chi.NewRouter()
r.Route("/v1", func(r chi.Router) {
r.Route("/article", func(r chi.Router) {
r.Use(jwtauth.Verifier(token))
r.Use(authtoken.Verifier(token))
r.Get("/", articleHandler.ListArticles)
r.Get("/{id}", articleHandler.GetArticle)
r.Group(func(r chi.Router) {
r.Use(jwtauth.Authenticator(token))
r.Use(authtoken.Authenticator)
r.Use(middleware.RequireVerifiedUser(getUserUseCase))
r.Post("/", articleHandler.PostArticle)
r.Delete("/{id}", articleHandler.DeleteArticle)
@@ -65,9 +65,9 @@ func newPublicArticleReadUserRepo(isVerified bool) *mockUserRepo {
return repo
}
func mintPublicArticleReadCookie(t *testing.T, token *jwtauth.JWTAuth, userId uint32) *http.Cookie {
func mintPublicArticleReadCookie(t *testing.T, token *authtoken.Auth, userId uint32) *http.Cookie {
t.Helper()
_, tokenString, err := token.Encode(map[string]any{"user_id": userId})
tokenString, err := token.Encode(map[string]any{"user_id": userId})
if err != nil {
t.Fatalf("failed to encode jwt: %v", err)
}
@@ -100,7 +100,7 @@ func assertPublicArticleReadNotFound(t *testing.T, rec *httptest.ResponseRecorde
func TestPublicArticleReadHttp(t *testing.T) {
validator.InitValidator()
token := jwtauth.New("HS256", []byte("test-secret"), nil)
token := authtoken.New([]byte("test-secret"))
t.Run("An anonymous visitor can read an online article", func(t *testing.T) {
router := newPublicArticleReadRouter(seedPublicArticleReadRepo(), newMockUserRepo(), token)
@@ -14,8 +14,8 @@ import (
"testing"
"time"
"RenewCMS/api/pkg/authtoken"
"github.com/go-chi/chi/v5"
"github.com/go-chi/jwtauth/v5"
)
type sequentialHasher struct {
@@ -38,7 +38,7 @@ func (s *sequentialHasher) Compare(hashed string, value string) error {
}
func newRegistrationGateRouter(userRepo *mockUserRepo, mailRepo *mockMailRepo, publicURL string) *chi.Mux {
token := jwtauth.New("HS256", []byte("test-secret"), nil)
token := authtoken.New([]byte("test-secret"))
hasher := &stubHasher{}
loginHandler := auth.NewLoginHandler(
token,
@@ -11,12 +11,12 @@ import (
"strings"
"testing"
"RenewCMS/api/pkg/authtoken"
"github.com/go-chi/chi/v5"
"github.com/go-chi/jwtauth/v5"
)
func newRegistrationStatusRouter(userRepo *mockUserRepo, mailRepo *mockMailRepo) (*chi.Mux, *useCases.CreateUserUseCase) {
token := jwtauth.New("HS256", []byte("test-secret"), nil)
token := authtoken.New([]byte("test-secret"))
hasher := &stubHasher{}
createUseCase := useCases.NewCreateUserUseCase(userRepo, hasher, mailRepo, "https://cms.example.com", discardRecorder)
loginHandler := auth.NewLoginHandler(token, createUseCase, useCases.NewAuthenticateUserUseCase(userRepo, hasher, discardRecorder), true)
+3 -3
View File
@@ -18,13 +18,13 @@ import (
"path/filepath"
"testing"
"RenewCMS/api/pkg/authtoken"
"github.com/go-chi/chi/v5"
"github.com/go-chi/jwtauth/v5"
)
func newSecureCookieAuthRouter(t *testing.T, isProduction bool) (*chi.Mux, *mockUserRepo) {
t.Helper()
token := jwtauth.New("HS256", []byte("test-secret"), nil)
token := authtoken.New([]byte("test-secret"))
userRepo := newMockUserRepo()
hasher := hashing.NewBcryptHasher()
hashed, err := hasher.Hash("hunter2example")
@@ -125,7 +125,7 @@ func newRuntimeSettingsContainer(t *testing.T) *api.UseCases {
imageRepo := newMockImageRepo()
mailRepo := newMockMailRepo()
hasher := hashing.NewBcryptHasher()
token := jwtauth.New("HS256", []byte("test-secret"), nil)
token := authtoken.New([]byte("test-secret"))
return &api.UseCases{
Token: token,
+9 -9
View File
@@ -11,11 +11,11 @@ import (
"strings"
"testing"
"RenewCMS/api/pkg/authtoken"
"github.com/go-chi/chi/v5"
"github.com/go-chi/jwtauth/v5"
)
func newSettingsRouter(t *testing.T, userRepo *mockUserRepo, token *jwtauth.JWTAuth, version string) *chi.Mux {
func newSettingsRouter(t *testing.T, userRepo *mockUserRepo, token *authtoken.Auth, version string) *chi.Mux {
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
settingsHandler := settings.NewHandler(
useCases.NewGetSettingsUseCase(version),
@@ -25,8 +25,8 @@ func newSettingsRouter(t *testing.T, userRepo *mockUserRepo, token *jwtauth.JWTA
r := chi.NewRouter()
r.Route("/v1/settings", func(r chi.Router) {
r.Use(jwtauth.Verifier(token))
r.Use(jwtauth.Authenticator(token))
r.Use(authtoken.Verifier(token))
r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(getUserUseCase))
r.Use(middleware.RequireVerifiedUser(getUserUseCase))
r.Get("/", settingsHandler.GetSettings)
@@ -43,7 +43,7 @@ func TestSettingsHttp(t *testing.T) {
for _, version := range []string{"v2.2.0-beta", "dev"} {
t.Run("A verified caller gets the running build's version: "+version, func(t *testing.T) {
token := jwtauth.New("HS256", []byte("test-secret"), nil)
token := authtoken.New([]byte("test-secret"))
userRepo := newMockUserRepo()
userRepo.users[7] = user.User{ID: 7, Username: "alice", IsVerified: true, TokenVersion: 0}
router := newSettingsRouter(t, userRepo, token, version)
@@ -61,7 +61,7 @@ func TestSettingsHttp(t *testing.T) {
}
t.Run("The response does not depend on the environment at request time", func(t *testing.T) {
token := jwtauth.New("HS256", []byte("test-secret"), nil)
token := authtoken.New([]byte("test-secret"))
userRepo := newMockUserRepo()
userRepo.users[7] = user.User{ID: 7, Username: "alice", IsVerified: true, TokenVersion: 0}
router := newSettingsRouter(t, userRepo, token, "v2.2.0-beta")
@@ -79,7 +79,7 @@ func TestSettingsHttp(t *testing.T) {
})
t.Run("A request without a jwt cookie is refused", func(t *testing.T) {
token := jwtauth.New("HS256", []byte("test-secret"), nil)
token := authtoken.New([]byte("test-secret"))
router := newSettingsRouter(t, newMockUserRepo(), token, "v2.2.0-beta")
rec := sendGetCurrentUserRequest(router, http.MethodGet, "/v1/settings", nil)
@@ -93,7 +93,7 @@ func TestSettingsHttp(t *testing.T) {
})
t.Run("A token whose version no longer matches the account is refused", func(t *testing.T) {
token := jwtauth.New("HS256", []byte("test-secret"), nil)
token := authtoken.New([]byte("test-secret"))
userRepo := newMockUserRepo()
userRepo.users[7] = user.User{ID: 7, Username: "alice", IsVerified: true, TokenVersion: 1}
router := newSettingsRouter(t, userRepo, token, "v2.2.0-beta")
@@ -110,7 +110,7 @@ func TestSettingsHttp(t *testing.T) {
})
t.Run("An unverified account is refused", func(t *testing.T) {
token := jwtauth.New("HS256", []byte("test-secret"), nil)
token := authtoken.New([]byte("test-secret"))
userRepo := newMockUserRepo()
userRepo.users[8] = user.User{ID: 8, Username: "bob", IsVerified: false, TokenVersion: 0}
router := newSettingsRouter(t, userRepo, token, "v2.2.0-beta")
+284
View File
@@ -0,0 +1,284 @@
package test
import (
"RenewCMS/internal/infrastructure/config"
"RenewCMS/internal/infrastructure/mailer"
"RenewCMS/internal/infrastructure/useCases"
"io"
"mime"
"mime/quotedprintable"
"net/mail"
"strings"
"testing"
"time"
)
var smtpMailerClock = time.Date(2026, time.March, 1, 12, 0, 0, 0, time.UTC)
func smtpMailerFor(server *fakeSMTPServer, settings config.SmtpSettings) *mailer.MailRepository {
if settings.Host == "" {
settings.Host = "127.0.0.1"
}
settings.Port = server.port()
if settings.Sender == "" {
settings.Sender = "notify@example.com"
}
return mailer.NewMailRepository(settings, mailer.WithTLSConfig(server.clientTLS()), mailer.WithClock(func() time.Time { return smtpMailerClock }))
}
func verificationData() useCases.MailValidationData {
return useCases.MailValidationData{Host: "https://cms.example.com", VerificationCode: "abc123"}
}
func parseDeliveredMail(t *testing.T, raw string) (*mail.Message, string) {
t.Helper()
message, err := mail.ReadMessage(strings.NewReader(raw))
if err != nil {
t.Fatalf("delivered data is not a valid message: %v\n%s", err, raw)
}
decoded, err := io.ReadAll(quotedprintable.NewReader(message.Body))
if err != nil {
t.Fatalf("body is not valid quoted-printable: %v", err)
}
return message, string(decoded)
}
func TestSMTPMailerDelivery(t *testing.T) {
t.Run("Delivers a well-formed HTML message to a plain local server", func(t *testing.T) {
server := newFakeSMTPServer(t, fakeSMTPOptions{})
err := smtpMailerFor(server, config.SmtpSettings{}).Send("reader@example.org", "verification", verificationData())
if err != nil {
t.Fatalf("send failed: %v", err)
}
sessions := server.received()
if len(sessions) != 1 || sessions[0].from != "notify@example.com" || sessions[0].to != "reader@example.org" {
t.Fatalf("unexpected envelope %+v", sessions)
}
message, body := parseDeliveredMail(t, sessions[0].data)
if got := message.Header.Get("From"); got != `"RenewCMS" <notify@example.com>` {
t.Errorf("unexpected From %q", got)
}
if got := message.Header.Get("To"); got != "<reader@example.org>" {
t.Errorf("unexpected To %q", got)
}
subject, _ := new(mime.WordDecoder).DecodeHeader(message.Header.Get("Subject"))
if subject != "Verify your RenewCMS account" {
t.Errorf("unexpected Subject %q", subject)
}
if got := message.Header.Get("Content-Type"); got != "text/html; charset=UTF-8" {
t.Errorf("unexpected Content-Type %q", got)
}
if got := message.Header.Get("MIME-Version"); got != "1.0" {
t.Errorf("unexpected MIME-Version %q", got)
}
if !strings.Contains(body, "https://cms.example.com/register/validate?c=abc123") {
t.Errorf("verification link missing from body %q", body)
}
})
t.Run("Stamps a Date from the clock and a unique Message-ID on the sender's domain", func(t *testing.T) {
server := newFakeSMTPServer(t, fakeSMTPOptions{})
repo := smtpMailerFor(server, config.SmtpSettings{})
_ = repo.Send("a@example.org", "verification", verificationData())
_ = repo.Send("a@example.org", "verification", verificationData())
sessions := server.received()
first, _ := parseDeliveredMail(t, sessions[0].data)
second, _ := parseDeliveredMail(t, sessions[1].data)
date, err := first.Header.Date()
if err != nil || !date.Equal(smtpMailerClock) {
t.Errorf("expected Date %v, got %v (%v)", smtpMailerClock, date, err)
}
id := first.Header.Get("Message-ID")
if !strings.HasPrefix(id, "<") || !strings.HasSuffix(id, "@example.com>") {
t.Errorf("unexpected Message-ID %q", id)
}
if id == second.Header.Get("Message-ID") {
t.Error("Message-IDs must be unique")
}
})
t.Run("Encodes a non-ASCII subject and body without corrupting them", func(t *testing.T) {
server := newFakeSMTPServer(t, fakeSMTPOptions{})
repo := smtpMailerFor(server, config.SmtpSettings{})
err := repo.Send("reader@example.org", "verification", verificationData())
if err != nil {
t.Fatal(err)
}
raw := server.received()[0].data
headerBlock, _, _ := strings.Cut(raw, "\r\n\r\n")
for _, r := range headerBlock {
if r > 127 {
t.Fatalf("header contains a raw non-ASCII rune %q", r)
}
}
})
t.Run("Authenticates with PLAIN over STARTTLS", func(t *testing.T) {
server := newFakeSMTPServer(t, fakeSMTPOptions{startTLS: true, authMechanisms: "PLAIN LOGIN"})
settings := config.SmtpSettings{Login: "user@example.com", Password: "s3cret"}
err := smtpMailerFor(server, settings).Send("reader@example.org", "verification", verificationData())
if err != nil {
t.Fatalf("send failed: %v", err)
}
session := server.received()[0]
if !session.usedTLS || session.username != "user@example.com" || session.password != "s3cret" {
t.Errorf("unexpected session %+v", session)
}
})
t.Run("Falls back to LOGIN when PLAIN is not offered", func(t *testing.T) {
server := newFakeSMTPServer(t, fakeSMTPOptions{startTLS: true, authMechanisms: "LOGIN"})
settings := config.SmtpSettings{Login: "user@example.com", Password: "s3cret"}
err := smtpMailerFor(server, settings).Send("reader@example.org", "verification", verificationData())
if err != nil {
t.Fatalf("send failed: %v", err)
}
session := server.received()[0]
if session.username != "user@example.com" || session.password != "s3cret" {
t.Errorf("unexpected credentials %+v", session)
}
})
t.Run("Skips authentication when no login is configured", func(t *testing.T) {
server := newFakeSMTPServer(t, fakeSMTPOptions{authMechanisms: "PLAIN"})
err := smtpMailerFor(server, config.SmtpSettings{}).Send("reader@example.org", "verification", verificationData())
if err != nil {
t.Fatal(err)
}
for _, command := range server.received()[0].commands {
if strings.HasPrefix(strings.ToUpper(command), "AUTH") {
t.Errorf("unexpected %q", command)
}
}
})
for _, mechanisms := range []string{"PLAIN", "LOGIN"} {
t.Run("Refuses to send "+mechanisms+" credentials in clear text to a non-loopback server", func(t *testing.T) {
server := newFakeSMTPServer(t, fakeSMTPOptions{authMechanisms: mechanisms, listenAddress: "127.0.0.2:0"})
settings := config.SmtpSettings{Login: "user@example.com", Password: "s3cret", Host: "127.0.0.2"}
err := smtpMailerFor(server, settings).Send("reader@example.org", "verification", verificationData())
if err == nil {
t.Fatal("expected an error")
}
if session := server.received()[0]; session.password != "" || session.data != "" {
t.Errorf("credentials or data leaked: %+v", session)
}
})
}
t.Run("Fails when credentials are configured but the server offers no AUTH", func(t *testing.T) {
server := newFakeSMTPServer(t, fakeSMTPOptions{})
settings := config.SmtpSettings{Login: "user@example.com", Password: "s3cret"}
err := smtpMailerFor(server, settings).Send("reader@example.org", "verification", verificationData())
if err == nil {
t.Fatal("expected an error")
}
if len(server.received()[0].data) != 0 {
t.Error("no message may be delivered")
}
})
t.Run("Fails the STARTTLS handshake against an untrusted certificate", func(t *testing.T) {
server := newFakeSMTPServer(t, fakeSMTPOptions{startTLS: true})
repo := mailer.NewMailRepository(config.SmtpSettings{Host: "127.0.0.1", Port: server.port(), Sender: "notify@example.com"})
err := repo.Send("reader@example.org", "verification", verificationData())
if err == nil {
t.Fatal("expected a certificate error")
}
if len(server.received()[0].data) != 0 {
t.Error("no message may be delivered")
}
})
t.Run("Uses implicit TLS on port 465", func(t *testing.T) {
server := newFakeSMTPServer(t, fakeSMTPOptions{implicitTLS: true, listenAddress: "127.0.0.1:465"})
repo := mailer.NewMailRepository(config.SmtpSettings{Host: "127.0.0.1", Port: 465, Sender: "notify@example.com"}, mailer.WithTLSConfig(server.clientTLS()))
err := repo.Send("reader@example.org", "verification", verificationData())
if err != nil {
t.Fatalf("send failed: %v", err)
}
if !server.received()[0].usedTLS {
t.Error("expected a TLS session")
}
})
t.Run("Surfaces a rejected recipient", func(t *testing.T) {
server := newFakeSMTPServer(t, fakeSMTPOptions{rejectRcpt: true})
err := smtpMailerFor(server, config.SmtpSettings{}).Send("reader@example.org", "verification", verificationData())
if err == nil {
t.Fatal("expected an error")
}
})
t.Run("Surfaces a rejected DATA command", func(t *testing.T) {
server := newFakeSMTPServer(t, fakeSMTPOptions{rejectData: true})
err := smtpMailerFor(server, config.SmtpSettings{}).Send("reader@example.org", "verification", verificationData())
if err == nil {
t.Fatal("expected an error")
}
})
t.Run("Surfaces an unreachable server", func(t *testing.T) {
server := newFakeSMTPServer(t, fakeSMTPOptions{})
repo := smtpMailerFor(server, config.SmtpSettings{})
_ = server.listener.Close()
if err := repo.Send("reader@example.org", "verification", verificationData()); err == nil {
t.Fatal("expected an error")
}
})
}
func TestSMTPMailerRejectsUnsafeInput(t *testing.T) {
cases := map[string]struct {
sender string
receiver string
template string
}{
"receiver with an injected header": {"notify@example.com", "a@example.org\r\nBcc: evil@example.net", "verification"},
"receiver that is not an address": {"notify@example.com", "not-an-address", "verification"},
"empty receiver": {"notify@example.com", "", "verification"},
"sender that is not an address": {"nope", "a@example.org", "verification"},
"unknown template": {"notify@example.com", "a@example.org", "does-not-exist"},
}
for name, tc := range cases {
t.Run("Rejects a "+name+" before opening a connection", func(t *testing.T) {
server := newFakeSMTPServer(t, fakeSMTPOptions{})
repo := smtpMailerFor(server, config.SmtpSettings{Sender: tc.sender})
err := repo.Send(tc.receiver, tc.template, verificationData())
if err == nil {
t.Fatal("expected an error")
}
if len(server.received()) != 0 {
t.Errorf("no connection may be made, got %d", len(server.received()))
}
})
}
}
+261
View File
@@ -0,0 +1,261 @@
package test
import (
"bufio"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/base64"
"math/big"
"net"
"strings"
"sync"
"testing"
"time"
)
type fakeSMTPOptions struct {
authMechanisms string
startTLS bool
rejectRcpt bool
rejectData bool
implicitTLS bool
listenAddress string
}
type fakeSMTPSession struct {
commands []string
from string
to string
data string
username string
password string
usedTLS bool
}
type fakeSMTPServer struct {
listener net.Listener
options fakeSMTPOptions
cert tls.Certificate
roots *x509.CertPool
mu sync.Mutex
sessions []*fakeSMTPSession
wg sync.WaitGroup
}
func newFakeSMTPServer(t *testing.T, options fakeSMTPOptions) *fakeSMTPServer {
t.Helper()
address := options.listenAddress
if address == "" {
address = "127.0.0.1:0"
}
listener, err := net.Listen("tcp", address)
if err != nil {
t.Skipf("cannot listen on %s: %v", address, err)
}
cert, roots := selfSignedLoopbackCert(t)
server := &fakeSMTPServer{listener: listener, options: options, cert: cert, roots: roots}
server.wg.Add(1)
go server.acceptLoop()
t.Cleanup(func() {
_ = listener.Close()
server.wg.Wait()
})
return server
}
func selfSignedLoopbackCert(t *testing.T) (tls.Certificate, *x509.CertPool) {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
template := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "127.0.0.1"},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
}
der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
if err != nil {
t.Fatal(err)
}
parsed, err := x509.ParseCertificate(der)
if err != nil {
t.Fatal(err)
}
roots := x509.NewCertPool()
roots.AddCert(parsed)
return tls.Certificate{Certificate: [][]byte{der}, PrivateKey: key}, roots
}
func (s *fakeSMTPServer) port() int {
return s.listener.Addr().(*net.TCPAddr).Port
}
func (s *fakeSMTPServer) clientTLS() *tls.Config {
return &tls.Config{RootCAs: s.roots}
}
func (s *fakeSMTPServer) received() []*fakeSMTPSession {
s.mu.Lock()
defer s.mu.Unlock()
return append([]*fakeSMTPSession(nil), s.sessions...)
}
func (s *fakeSMTPServer) acceptLoop() {
defer s.wg.Done()
for {
connection, err := s.listener.Accept()
if err != nil {
return
}
s.wg.Add(1)
go func() {
defer s.wg.Done()
s.serve(connection)
}()
}
}
func (s *fakeSMTPServer) serve(connection net.Conn) {
defer func() { _ = connection.Close() }()
_ = connection.SetDeadline(time.Now().Add(10 * time.Second))
session := &fakeSMTPSession{}
s.mu.Lock()
s.sessions = append(s.sessions, session)
s.mu.Unlock()
if s.options.implicitTLS {
connection = tls.Server(connection, &tls.Config{Certificates: []tls.Certificate{s.cert}})
session.usedTLS = true
}
reader := bufio.NewReader(connection)
reply := func(line string) { _, _ = connection.Write([]byte(line + "\r\n")) }
reply("220 fake ESMTP")
for {
line, err := reader.ReadString('\n')
if err != nil {
return
}
line = strings.TrimRight(line, "\r\n")
s.record(session, line)
verb := strings.ToUpper(strings.Fields(line + " ")[0])
switch verb {
case "EHLO":
s.replyEhlo(reply, session)
case "STARTTLS":
reply("220 ready")
connection = tls.Server(connection, &tls.Config{Certificates: []tls.Certificate{s.cert}})
reader = bufio.NewReader(connection)
reply = func(line string) { _, _ = connection.Write([]byte(line + "\r\n")) }
session.usedTLS = true
case "AUTH":
if !s.authenticate(line, reader, reply, session) {
return
}
case "MAIL":
session.from = smtpAngleContent(line, "<", ">")
reply("250 ok")
case "RCPT":
if s.options.rejectRcpt {
reply("550 no such user")
continue
}
session.to = smtpAngleContent(line, "<", ">")
reply("250 ok")
case "DATA":
if s.options.rejectData {
reply("554 refused")
continue
}
reply("354 go ahead")
session.data = readSMTPData(reader)
reply("250 queued")
case "QUIT":
reply("221 bye")
return
default:
reply("250 ok")
}
}
}
func (s *fakeSMTPServer) record(session *fakeSMTPSession, line string) {
s.mu.Lock()
session.commands = append(session.commands, line)
s.mu.Unlock()
}
func (s *fakeSMTPServer) replyEhlo(reply func(string), session *fakeSMTPSession) {
lines := []string{"250-fake"}
if s.options.startTLS && !session.usedTLS {
lines = append(lines, "250-STARTTLS")
}
if s.options.authMechanisms != "" {
lines = append(lines, "250-AUTH "+s.options.authMechanisms)
}
lines = append(lines, "250 8BITMIME")
for _, line := range lines {
reply(line)
}
}
func (s *fakeSMTPServer) authenticate(line string, reader *bufio.Reader, reply func(string), session *fakeSMTPSession) bool {
fields := strings.Fields(line)
if len(fields) < 2 {
reply("501 syntax")
return false
}
switch strings.ToUpper(fields[1]) {
case "PLAIN":
raw, _ := base64.StdEncoding.DecodeString(fields[2])
parts := strings.Split(string(raw), "\x00")
session.username, session.password = parts[1], parts[2]
case "LOGIN":
reply("334 " + base64.StdEncoding.EncodeToString([]byte("Username:")))
username, _ := reader.ReadString('\n')
reply("334 " + base64.StdEncoding.EncodeToString([]byte("Password:")))
password, _ := reader.ReadString('\n')
session.username = decodeBase64Line(username)
session.password = decodeBase64Line(password)
default:
reply("504 unsupported")
return false
}
reply("235 ok")
return true
}
func decodeBase64Line(line string) string {
raw, _ := base64.StdEncoding.DecodeString(strings.TrimSpace(line))
return string(raw)
}
func smtpAngleContent(line string, open string, close string) string {
start := strings.Index(line, open)
end := strings.LastIndex(line, close)
if start < 0 || end <= start {
return ""
}
return line[start+1 : end]
}
func readSMTPData(reader *bufio.Reader) string {
var data strings.Builder
for {
line, err := reader.ReadString('\n')
if err != nil || line == ".\r\n" {
return data.String()
}
data.WriteString(strings.TrimPrefix(line, "."))
}
}
+15 -15
View File
@@ -16,11 +16,11 @@ import (
"testing"
"time"
"RenewCMS/api/pkg/authtoken"
"github.com/go-chi/chi/v5"
"github.com/go-chi/jwtauth/v5"
)
func newStaleTokenInvalidationRouter(userRepo *mockUserRepo, articleRepo *mockArticleRepo, imageRepo *mockImageRepo, mailRepo *mockMailRepo, hasher *stubHasher, token *jwtauth.JWTAuth) *chi.Mux {
func newStaleTokenInvalidationRouter(userRepo *mockUserRepo, articleRepo *mockArticleRepo, imageRepo *mockImageRepo, mailRepo *mockMailRepo, hasher *stubHasher, token *authtoken.Auth) *chi.Mux {
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
articleHandler := article.NewHandler(
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
@@ -50,8 +50,8 @@ func newStaleTokenInvalidationRouter(userRepo *mockUserRepo, articleRepo *mockAr
r.Route("/v1", func(r chi.Router) {
r.Route("/article", func(r chi.Router) {
r.Group(func(r chi.Router) {
r.Use(jwtauth.Verifier(token))
r.Use(jwtauth.Authenticator(token))
r.Use(authtoken.Verifier(token))
r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(getUserUseCase))
r.Use(middleware.RequireVerifiedUser(getUserUseCase))
r.Post("/", articleHandler.PostArticle)
@@ -63,8 +63,8 @@ func newStaleTokenInvalidationRouter(userRepo *mockUserRepo, articleRepo *mockAr
r.Post("/password-reset", passwordResetHandler.RequestPasswordReset)
r.Post("/password-reset/confirm", passwordResetHandler.ResetPassword)
r.Group(func(r chi.Router) {
r.Use(jwtauth.Verifier(token))
r.Use(jwtauth.Authenticator(token))
r.Use(authtoken.Verifier(token))
r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(getUserUseCase))
r.Post("/verify", verificationHandler.Verify)
r.Delete("/register", verificationHandler.CancelRegistration)
@@ -90,9 +90,9 @@ func newStaleTokenAliceRepo(isVerified bool) *mockUserRepo {
return repo
}
func mintStaleTokenCookie(t *testing.T, token *jwtauth.JWTAuth, claims map[string]any) *http.Cookie {
func mintStaleTokenCookie(t *testing.T, token *authtoken.Auth, claims map[string]any) *http.Cookie {
t.Helper()
_, tokenString, err := token.Encode(claims)
tokenString, err := token.Encode(claims)
if err != nil {
t.Fatalf("failed to encode jwt: %v", err)
}
@@ -164,7 +164,7 @@ func TestStaleTokenInvalidationHttp(t *testing.T) {
validator.InitValidator()
t.Run("A jwt minted before a password reset is refused afterward, but a fresh login's jwt is accepted", func(t *testing.T) {
token := jwtauth.New("HS256", []byte("test-secret"), nil)
token := authtoken.New([]byte("test-secret"))
userRepo := newStaleTokenAliceRepo(true)
articleRepo := newMockArticleRepo()
router := newStaleTokenInvalidationRouter(userRepo, articleRepo, newMockImageRepo(), newMockMailRepo(), &stubHasher{}, token)
@@ -211,7 +211,7 @@ func TestStaleTokenInvalidationHttp(t *testing.T) {
})
t.Run("A stale jwt is also refused on the auth group's own protected routes, not just article routes", func(t *testing.T) {
token := jwtauth.New("HS256", []byte("test-secret"), nil)
token := authtoken.New([]byte("test-secret"))
userRepo := newStaleTokenAliceRepo(false)
router := newStaleTokenInvalidationRouter(userRepo, newMockArticleRepo(), newMockImageRepo(), newMockMailRepo(), &stubHasher{}, token)
preResetCookie := mintStaleTokenCookie(t, token, map[string]any{"user_id": uint32(7), "token_version": uint32(0)})
@@ -242,7 +242,7 @@ func TestStaleTokenInvalidationHttp(t *testing.T) {
})
t.Run("Resetting the password twice invalidates both the original jwt and the jwt from the interim login", func(t *testing.T) {
token := jwtauth.New("HS256", []byte("test-secret"), nil)
token := authtoken.New([]byte("test-secret"))
userRepo := newStaleTokenAliceRepo(true)
articleRepo := newMockArticleRepo()
router := newStaleTokenInvalidationRouter(userRepo, articleRepo, newMockImageRepo(), newMockMailRepo(), &stubHasher{}, token)
@@ -298,7 +298,7 @@ func TestStaleTokenInvalidationHttp(t *testing.T) {
})
t.Run("Resetting one user's password never invalidates another user's jwt", func(t *testing.T) {
token := jwtauth.New("HS256", []byte("test-secret"), nil)
token := authtoken.New([]byte("test-secret"))
userRepo := newStaleTokenAliceRepo(true)
userRepo.users[9] = user.User{ID: 9, Username: "bob", IsVerified: true, TokenVersion: 0}
articleRepo := newMockArticleRepo()
@@ -329,7 +329,7 @@ func TestStaleTokenInvalidationHttp(t *testing.T) {
}
for _, tc := range cancelledRegistrationCases {
t.Run("A cancelled registration still refuses a jwt minted before the cancellation, on every protected route: "+tc.method+" "+tc.path, func(t *testing.T) {
token := jwtauth.New("HS256", []byte("test-secret"), nil)
token := authtoken.New([]byte("test-secret"))
userRepo := newStaleTokenAliceRepo(false)
router := newStaleTokenInvalidationRouter(userRepo, newMockArticleRepo(), newMockImageRepo(), newMockMailRepo(), &stubHasher{}, token)
preCancelCookie := mintStaleTokenCookie(t, token, map[string]any{"user_id": uint32(7), "token_version": uint32(0)})
@@ -350,7 +350,7 @@ func TestStaleTokenInvalidationHttp(t *testing.T) {
}
t.Run("A jwt with no token_version claim at all is refused, treated the same as a mismatch", func(t *testing.T) {
token := jwtauth.New("HS256", []byte("test-secret"), nil)
token := authtoken.New([]byte("test-secret"))
userRepo := newStaleTokenAliceRepo(true)
router := newStaleTokenInvalidationRouter(userRepo, newMockArticleRepo(), newMockImageRepo(), newMockMailRepo(), &stubHasher{}, token)
cookie := mintStaleTokenCookie(t, token, map[string]any{"user_id": uint32(7)})
@@ -365,7 +365,7 @@ func TestStaleTokenInvalidationHttp(t *testing.T) {
})
t.Run("A jwt whose token_version claim is a string, not a number, is refused instead of crashing", func(t *testing.T) {
token := jwtauth.New("HS256", []byte("test-secret"), nil)
token := authtoken.New([]byte("test-secret"))
userRepo := newStaleTokenAliceRepo(true)
router := newStaleTokenInvalidationRouter(userRepo, newMockArticleRepo(), newMockImageRepo(), newMockMailRepo(), &stubHasher{}, token)
cookie := mintStaleTokenCookie(t, token, map[string]any{"user_id": uint32(7), "token_version": "0"})
@@ -13,14 +13,14 @@ import (
"testing"
"time"
"RenewCMS/api/pkg/authtoken"
"github.com/go-chi/chi/v5"
"github.com/go-chi/jwtauth/v5"
)
func newTrustedProxyAuthRouter(t *testing.T, userRepo user.Repository, mailRepo *mockMailRepo, trustedProxies []string) *chi.Mux {
t.Helper()
hasher := hashing.NewBcryptHasher()
token := jwtauth.New("HS256", []byte("test-secret"), nil)
token := authtoken.New([]byte("test-secret"))
loginHandler := auth.NewLoginHandler(
token,
useCases.NewCreateUserUseCase(userRepo, hasher, mailRepo, "https://cms.example.com", discardRecorder),
@@ -16,8 +16,8 @@ import (
"testing"
"time"
"RenewCMS/api/pkg/authtoken"
"github.com/go-chi/chi/v5"
"github.com/go-chi/jwtauth/v5"
)
func newMigratedUserRepository(t *testing.T) *persistence.UserRepository {
@@ -151,7 +151,7 @@ func TestUnverifiedUsersAndReregistrationRepository(t *testing.T) {
})
}
func newUnverifiedRegistrationRouter(userRepo *mockUserRepo, token *jwtauth.JWTAuth) *chi.Mux {
func newUnverifiedRegistrationRouter(userRepo *mockUserRepo, token *authtoken.Auth) *chi.Mux {
hasher := hashing.NewBcryptHasher()
loginHandler := auth.NewLoginHandler(
token,
@@ -184,7 +184,7 @@ func registerViaHttp(t *testing.T, router *chi.Mux, username, password, email st
func TestUnverifiedUsersAndReregistrationHttp(t *testing.T) {
validator.InitValidator()
token := jwtauth.New("HS256", []byte("test-secret"), nil)
token := authtoken.New([]byte("test-secret"))
t.Run("Two unverified users can register one after another without a reset-code collision", func(t *testing.T) {
repo := newMockUserRepo()
+2 -2
View File
@@ -23,7 +23,7 @@ import (
"testing"
"time"
"github.com/go-chi/jwtauth/v5"
"RenewCMS/api/pkg/authtoken"
)
const downloadPath = "/update/download"
@@ -31,7 +31,7 @@ const downloadPath = "/update/download"
func newDownloadHTTP(t *testing.T, rig *downloadRig) *updateHarness {
t.Helper()
validator.InitValidator()
token := jwtauth.New("HS256", []byte("test-secret"), nil)
token := authtoken.New([]byte("test-secret"))
users := newUpdateUserRepo()
feed := &fakeReleaseFeed{}
now := func() time.Time { return updateTestCheckedAt }
+4 -4
View File
@@ -16,7 +16,7 @@ import (
"testing"
"time"
"github.com/go-chi/jwtauth/v5"
"RenewCMS/api/pkg/authtoken"
)
const (
@@ -32,7 +32,7 @@ var updateStatusKeys = []string{"available", "can_apply", "checked_at", "contain
type updateHarness struct {
router http.Handler
token *jwtauth.JWTAuth
token *authtoken.Auth
feed *fakeReleaseFeed
store *fakeUpdateStore
}
@@ -58,7 +58,7 @@ func newUpdateHarness(t *testing.T, running string) *updateHarness {
Notes: update.ReleaseNotes{Version: "v2.3.0-beta", Items: []string{"Media library"}},
}}
store := &fakeUpdateStore{}
token := jwtauth.New("HS256", []byte("test-secret"), nil)
token := authtoken.New([]byte("test-secret"))
now := func() time.Time { return updateTestCheckedAt }
container := &api.UseCases{
Token: token,
@@ -74,7 +74,7 @@ func (h *updateHarness) send(t *testing.T, method, path string, userId uint32, t
t.Helper()
req := httptest.NewRequest(method, path, nil)
if userId != 0 {
_, tokenString, err := h.token.Encode(map[string]any{"user_id": userId, "token_version": tokenVersion})
tokenString, err := h.token.Encode(map[string]any{"user_id": userId, "token_version": tokenVersion})
if err != nil {
t.Fatal(err)
}
+6 -6
View File
@@ -20,14 +20,14 @@ import (
"testing"
"time"
"github.com/go-chi/jwtauth/v5"
"RenewCMS/api/pkg/authtoken"
)
const restartPath = "/update/restart"
type restartHTTP struct {
router http.Handler
token *jwtauth.JWTAuth
token *authtoken.Auth
dataDir string
markerPath string
resultPath string
@@ -44,7 +44,7 @@ func newRestartHTTP(t *testing.T) *restartHTTP {
validator.InitValidator()
dataDir := t.TempDir()
h := &restartHTTP{
token: jwtauth.New("HS256", []byte("test-secret"), nil),
token: authtoken.New([]byte("test-secret")),
dataDir: dataDir,
markerPath: filepath.Join(dataDir, update.RestartRequestFileName),
resultPath: filepath.Join(dataDir, update.ResultFileName),
@@ -86,7 +86,7 @@ func (h *restartHTTP) request(t *testing.T, method string, target string, userId
t.Helper()
req := httptest.NewRequest(method, target, strings.NewReader(body))
if userId != 0 {
_, tokenString, err := h.token.Encode(map[string]any{"user_id": userId, "token_version": tokenVersion})
tokenString, err := h.token.Encode(map[string]any{"user_id": userId, "token_version": tokenVersion})
if err != nil {
t.Fatal(err)
}
@@ -554,7 +554,7 @@ func TestRestartResultLivesUntilTheNextJob(t *testing.T) {
func TestHealthzIsUnchanged(t *testing.T) {
t.Run("Healthz is unchanged", func(t *testing.T) {
container := &api.UseCases{
Token: jwtauth.New("HS256", []byte("test-secret"), nil),
Token: authtoken.New([]byte("test-secret")),
OpenMediaUseCase: useCases.NewOpenMediaUseCase(storage.NewLocalStorage(t.TempDir())),
}
router := api.InitRoutes(container, config.Settings{Environment: "production"})
@@ -615,7 +615,7 @@ func TestRunningInfoFileAtStartup(t *testing.T) {
t.Run("the file is not served by any route", func(t *testing.T) {
container := &api.UseCases{
Token: jwtauth.New("HS256", []byte("test-secret"), nil),
Token: authtoken.New([]byte("test-secret")),
OpenMediaUseCase: useCases.NewOpenMediaUseCase(storage.NewLocalStorage(t.TempDir())),
}
router := api.InitRoutes(container, config.Settings{Environment: "production"})
+9 -9
View File
@@ -16,13 +16,13 @@ import (
"strings"
"testing"
"RenewCMS/api/pkg/authtoken"
"github.com/go-chi/chi/v5"
"github.com/go-chi/jwtauth/v5"
)
var displayNameResponseKeys = []string{"id", "username", "email", "is_verified", "created_at", "updated_at", "display_name", "role"}
func newDisplayNameRouter(userRepo *mockUserRepo, token *jwtauth.JWTAuth) *chi.Mux {
func newDisplayNameRouter(userRepo *mockUserRepo, token *authtoken.Auth) *chi.Mux {
hasher := &stubHasher{}
getUser := useCases.NewGetUserUseCase(userRepo)
update := useCases.NewUpdateUserProfileUseCase(userRepo, discardRecorder)
@@ -37,8 +37,8 @@ func newDisplayNameRouter(userRepo *mockUserRepo, token *jwtauth.JWTAuth) *chi.M
r := chi.NewRouter()
r.Route("/v1/user", func(r chi.Router) {
r.Use(jwtauth.Verifier(token))
r.Use(jwtauth.Authenticator(token))
r.Use(authtoken.Verifier(token))
r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(getUser))
r.Use(middleware.RequireVerifiedUser(getUser))
r.Patch("/me", handler.UpdateMe)
@@ -46,8 +46,8 @@ func newDisplayNameRouter(userRepo *mockUserRepo, token *jwtauth.JWTAuth) *chi.M
r.Route("/v1/auth", func(r chi.Router) {
r.Post("/login", loginHandler.Login)
r.Group(func(r chi.Router) {
r.Use(jwtauth.Verifier(token))
r.Use(jwtauth.Authenticator(token))
r.Use(authtoken.Verifier(token))
r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(getUser))
r.Get("/me", meHandler.Me)
})
@@ -55,9 +55,9 @@ func newDisplayNameRouter(userRepo *mockUserRepo, token *jwtauth.JWTAuth) *chi.M
return r
}
func displayNameCookie(t *testing.T, token *jwtauth.JWTAuth, userId uint32, tokenVersion uint32) *http.Cookie {
func displayNameCookie(t *testing.T, token *authtoken.Auth, userId uint32, tokenVersion uint32) *http.Cookie {
t.Helper()
_, tokenString, err := token.Encode(map[string]any{"user_id": userId, "token_version": tokenVersion})
tokenString, err := token.Encode(map[string]any{"user_id": userId, "token_version": tokenVersion})
if err != nil {
t.Fatalf("failed to encode jwt: %v", err)
}
@@ -205,7 +205,7 @@ func TestUpdateUserProfileUseCase(t *testing.T) {
func TestUserDisplayNameHttp(t *testing.T) {
validator.InitValidator()
token := jwtauth.New("HS256", []byte("test-secret"), nil)
token := authtoken.New([]byte("test-secret"))
patch := func(t *testing.T, repo *mockUserRepo, body string) *httptest.ResponseRecorder {
t.Helper()
+7 -7
View File
@@ -13,11 +13,11 @@ import (
"testing"
"time"
"RenewCMS/api/pkg/authtoken"
"github.com/go-chi/chi/v5"
"github.com/go-chi/jwtauth/v5"
)
func newUserFieldHygieneRouter(userRepo *mockUserRepo, mailRepo *mockMailRepo, token *jwtauth.JWTAuth) *chi.Mux {
func newUserFieldHygieneRouter(userRepo *mockUserRepo, mailRepo *mockMailRepo, token *authtoken.Auth) *chi.Mux {
hasher := &stubHasher{}
loginHandler := auth.NewLoginHandler(
token,
@@ -44,8 +44,8 @@ func newUserFieldHygieneRouter(userRepo *mockUserRepo, mailRepo *mockMailRepo, t
r.Post("/password-reset", passwordResetHandler.RequestPasswordReset)
r.Post("/password-reset/confirm", passwordResetHandler.ResetPassword)
r.Group(func(r chi.Router) {
r.Use(jwtauth.Verifier(token))
r.Use(jwtauth.Authenticator(token))
r.Use(authtoken.Verifier(token))
r.Use(authtoken.Authenticator)
r.Post("/verify", verificationHandler.Verify)
})
})
@@ -94,7 +94,7 @@ func assertNoForbiddenKeys(t *testing.T, body []byte) {
func TestUserResponseFieldHygiene(t *testing.T) {
validator.InitValidator()
token := jwtauth.New("HS256", []byte("test-secret"), nil)
token := authtoken.New([]byte("test-secret"))
t.Run("A successful verification response carries only public fields", func(t *testing.T) {
repo := newMockUserRepo()
@@ -111,7 +111,7 @@ func TestUserResponseFieldHygiene(t *testing.T) {
body, _ := json.Marshal(map[string]string{"code": "abc123"})
req := httptest.NewRequest(http.MethodPost, "/v1/auth/verify", bytes.NewReader(body))
_, tokenString, _ := token.Encode(map[string]any{"user_id": uint32(7)})
tokenString, _ := token.Encode(map[string]any{"user_id": uint32(7)})
req.AddCookie(&http.Cookie{Name: "jwt", Value: tokenString})
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
@@ -164,7 +164,7 @@ func TestUserResponseFieldHygiene(t *testing.T) {
body, _ := json.Marshal(map[string]string{"code": "wrong-code"})
req := httptest.NewRequest(http.MethodPost, "/v1/auth/verify", bytes.NewReader(body))
_, tokenString, _ := token.Encode(map[string]any{"user_id": uint32(7)})
tokenString, _ := token.Encode(map[string]any{"user_id": uint32(7)})
req.AddCookie(&http.Cookie{Name: "jwt", Value: tokenString})
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
+10 -10
View File
@@ -16,9 +16,9 @@ import (
"testing"
"time"
"RenewCMS/api/pkg/authtoken"
"github.com/glebarez/sqlite"
"github.com/go-chi/chi/v5"
"github.com/go-chi/jwtauth/v5"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
@@ -33,7 +33,7 @@ type userRolesFixture struct {
invites *inviteFakeRepo
mail *mockMailRepo
router *chi.Mux
token *jwtauth.JWTAuth
token *authtoken.Auth
}
func newUserRolesFixture(t *testing.T, maxAttempts int) *userRolesFixture {
@@ -43,7 +43,7 @@ func newUserRolesFixture(t *testing.T, maxAttempts int) *userRolesFixture {
users: newMockUserRepo(),
invites: newInviteFakeRepo(),
mail: newMockMailRepo(),
token: jwtauth.New("HS256", []byte("test-secret"), nil),
token: authtoken.New([]byte("test-secret")),
}
f.invites.users = f.users
hasher := &stubHasher{}
@@ -73,15 +73,15 @@ func newUserRolesFixture(t *testing.T, maxAttempts int) *userRolesFixture {
r.Post("/register", loginHandler.Register)
r.Post("/invite/accept", acceptHandler.AcceptInvite)
r.Group(func(r chi.Router) {
r.Use(jwtauth.Verifier(f.token))
r.Use(jwtauth.Authenticator(f.token))
r.Use(authtoken.Verifier(f.token))
r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(getUser))
r.Get("/me", meHandler.Me)
})
})
f.router.Route("/v1/user", func(r chi.Router) {
r.Use(jwtauth.Verifier(f.token))
r.Use(jwtauth.Authenticator(f.token))
r.Use(authtoken.Verifier(f.token))
r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(getUser))
r.Use(middleware.RequireVerifiedUser(getUser))
r.Get("/", inviteHandler.ListUsers)
@@ -386,12 +386,12 @@ func TestUserRolesUseCases(t *testing.T) {
func TestRequireOwnerRepositoryFailure(t *testing.T) {
t.Run("An unexpected repository error is a 500, not a 403", func(t *testing.T) {
token := jwtauth.New("HS256", []byte("test-secret"), nil)
token := authtoken.New([]byte("test-secret"))
repo := newMockUserRepo()
repo.users[1] = user.User{ID: 1, Username: "marta", IsVerified: true, Role: user.RoleOwner}
r := chi.NewRouter()
r.Use(jwtauth.Verifier(token))
r.Use(jwtauth.Authenticator(token))
r.Use(authtoken.Verifier(token))
r.Use(authtoken.Authenticator)
r.Use(middleware.RequireOwner(useCases.NewGetUserUseCase(repo)))
r.Post("/", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) })
repo.fail.failNext("Get")
+7 -7
View File
@@ -14,11 +14,11 @@ import (
"net/http/httptest"
"testing"
"RenewCMS/api/pkg/authtoken"
"github.com/go-chi/chi/v5"
"github.com/go-chi/jwtauth/v5"
)
func newVerifiedAccountRequiredRouter(articleRepo *mockArticleRepo, userRepo *mockUserRepo, token *jwtauth.JWTAuth) *chi.Mux {
func newVerifiedAccountRequiredRouter(articleRepo *mockArticleRepo, userRepo *mockUserRepo, token *authtoken.Auth) *chi.Mux {
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
articleHandler := article.NewHandler(
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
@@ -32,11 +32,11 @@ func newVerifiedAccountRequiredRouter(articleRepo *mockArticleRepo, userRepo *mo
r := chi.NewRouter()
r.Route("/v1", func(r chi.Router) {
r.Route("/article", func(r chi.Router) {
r.Use(jwtauth.Verifier(token))
r.Use(authtoken.Verifier(token))
r.Get("/", articleHandler.ListArticles)
r.Get("/{id}", articleHandler.GetArticle)
r.Group(func(r chi.Router) {
r.Use(jwtauth.Authenticator(token))
r.Use(authtoken.Authenticator)
r.Use(middleware.RequireVerifiedUser(getUserUseCase))
r.Post("/", articleHandler.PostArticle)
r.Delete("/{id}", articleHandler.DeleteArticle)
@@ -61,9 +61,9 @@ func seedVerifiedAccountRequiredUserRepo(isVerified bool) *mockUserRepo {
return repo
}
func mintVerifiedAccountRequiredCookie(t *testing.T, token *jwtauth.JWTAuth, claims map[string]any) *http.Cookie {
func mintVerifiedAccountRequiredCookie(t *testing.T, token *authtoken.Auth, claims map[string]any) *http.Cookie {
t.Helper()
_, tokenString, err := token.Encode(claims)
tokenString, err := token.Encode(claims)
if err != nil {
t.Fatalf("failed to encode jwt: %v", err)
}
@@ -81,7 +81,7 @@ func newVerifiedAccountRequiredRequest(method, target string, body string) *http
func TestVerifiedAccountRequiredHttp(t *testing.T) {
validator.InitValidator()
token := jwtauth.New("HS256", []byte("test-secret"), nil)
token := authtoken.New([]byte("test-secret"))
protectedRouteCases := []struct {
method string
+5 -5
View File
@@ -17,8 +17,8 @@ import (
"testing"
"time"
"RenewCMS/api/pkg/authtoken"
"github.com/go-chi/chi/v5"
"github.com/go-chi/jwtauth/v5"
"gorm.io/gorm"
)
@@ -208,7 +208,7 @@ func TestTheFakesMirrorTheRealRepositoriesOnEveryWriteNotFoundPath(t *testing.T)
}
}
func newDeleteArticleRouter(articleRepo *mockArticleRepo, token *jwtauth.JWTAuth) *chi.Mux {
func newDeleteArticleRouter(articleRepo *mockArticleRepo, token *authtoken.Auth) *chi.Mux {
userRepo := newVerifiedUserRepo()
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
@@ -224,8 +224,8 @@ func newDeleteArticleRouter(articleRepo *mockArticleRepo, token *jwtauth.JWTAuth
r := chi.NewRouter()
r.Route("/v1", func(r chi.Router) {
r.Group(func(r chi.Router) {
r.Use(jwtauth.Verifier(token))
r.Use(jwtauth.Authenticator(token))
r.Use(authtoken.Verifier(token))
r.Use(authtoken.Authenticator)
r.Use(middleware.RequireVerifiedUser(getUserUseCase))
r.Route("/article", func(r chi.Router) {
r.Delete("/{id}", articleHandler.DeleteArticle)
@@ -237,7 +237,7 @@ func newDeleteArticleRouter(articleRepo *mockArticleRepo, token *jwtauth.JWTAuth
func TestWriteNotFoundErrorsHttp(t *testing.T) {
validator.InitValidator()
token := jwtauth.New("HS256", []byte("test-secret"), nil)
token := authtoken.New([]byte("test-secret"))
t.Run("Deleting an unknown article answers 404, not a silent success or a 500", func(t *testing.T) {
articleRepo := newMockArticleRepo()