fixing style
This commit is contained in:
parent
e454940dfa
commit
f40485842d
35 changed files with 16000 additions and 286 deletions
|
|
@ -10,6 +10,12 @@ type Config struct {
|
|||
HTTPAddr, Profile, ContentFile, PostgresURL string
|
||||
AllowedOrigins, SSOHeader, SSOToken, SSOCookie string
|
||||
GoLoose GoLooseConfig
|
||||
|
||||
// ThemeBaseURL is the go-bananas origin supplying the shared styling contract.
|
||||
// Empty keeps this application's own fallbacks, which is the safe default: the
|
||||
// stylesheet is written as var(--bn-role, <colour>), so a missing contract
|
||||
// degrades to the palette it always had rather than to an unstyled page.
|
||||
ThemeBaseURL string
|
||||
}
|
||||
|
||||
// GoLooseConfig describes the Go Loose hosted-login integration. Go Tell is
|
||||
|
|
@ -39,6 +45,7 @@ func Load() Config {
|
|||
SSOHeader: get("CMS_SSO_HEADER", "X-SSO-Token"),
|
||||
SSOToken: get("CMS_SSO_TOKEN", ""),
|
||||
SSOCookie: get("CMS_SSO_COOKIE", ""),
|
||||
ThemeBaseURL: get("CMS_THEME_BASE_URL", ""),
|
||||
GoLoose: GoLooseConfig{
|
||||
Tenant: get("GO_LOOSE_TENANT", "tell"),
|
||||
AuthDomain: get("GO_LOOSE_AUTH_DOMAIN", "auth.dev"),
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
package golooseauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
|
|
@ -28,15 +29,24 @@ const (
|
|||
LogoutPath = "/api/auth/logout"
|
||||
)
|
||||
|
||||
// Auth is the Go Loose browser client plus the session lookup the API needs.
|
||||
// The login, callback, and logout handlers come from the Go Loose client; this
|
||||
// type only adds a non-redirecting way to turn a request into a user, because
|
||||
// the client middleware answers anonymous requests with a redirect and the CMS
|
||||
// has to answer its own API with a status code.
|
||||
// Auth is the Go Loose browser client plus the session lookup the API needs.
|
||||
// The login, callback, and logout handlers come from the Go Loose client; this
|
||||
// type only adds a non-redirecting way to turn a request into a user, because
|
||||
// the client middleware answers anonymous requests with a redirect and the CMS
|
||||
// has to answer its own API with a status code.
|
||||
type Auth struct {
|
||||
*goloose.BrowserAuth
|
||||
baseURL string
|
||||
httpClient *http.Client
|
||||
defaultAuth *goloose.BrowserAuth
|
||||
urpiAuth *goloose.BrowserAuth
|
||||
devAuth *goloose.BrowserAuth
|
||||
baseURL string
|
||||
urpiBase string
|
||||
devBase string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// New returns the browser client for the configured Go Loose application, or a
|
||||
|
|
@ -64,7 +74,7 @@ func New(cfg config.GoLooseConfig) (*Auth, error) {
|
|||
return nil, err
|
||||
}
|
||||
baseURL := "https://" + tenant + "." + authDomain
|
||||
browser, err := goloose.NewBrowserAuth(goloose.BrowserAuthConfig{
|
||||
defaultAuth, err := goloose.NewBrowserAuth(goloose.BrowserAuthConfig{
|
||||
BaseURL: baseURL,
|
||||
ClientID: cfg.ClientID,
|
||||
ClientSecret: cfg.ClientSecret,
|
||||
|
|
@ -79,12 +89,98 @@ func New(cfg config.GoLooseConfig) (*Auth, error) {
|
|||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Auth{BrowserAuth: browser, baseURL: baseURL, httpClient: httpClient}, nil
|
||||
|
||||
urpiBase := "https://" + tenant + ".auth.urpi.be"
|
||||
urpiAuth, _ := goloose.NewBrowserAuth(goloose.BrowserAuthConfig{
|
||||
BaseURL: urpiBase,
|
||||
ClientID: cfg.ClientID,
|
||||
ClientSecret: cfg.ClientSecret,
|
||||
RedirectURL: "https://tell.urpi.be" + CallbackPath,
|
||||
AfterLoginURL: "/",
|
||||
AfterLogoutURL: "/",
|
||||
LoginPath: LoginPath,
|
||||
CookieName: SessionCookie,
|
||||
CookieSecure: true,
|
||||
HTTPClient: httpClient,
|
||||
})
|
||||
|
||||
devBase := "https://" + tenant + ".auth.dev"
|
||||
devAuth, _ := goloose.NewBrowserAuth(goloose.BrowserAuthConfig{
|
||||
BaseURL: devBase,
|
||||
ClientID: cfg.ClientID,
|
||||
ClientSecret: cfg.ClientSecret,
|
||||
RedirectURL: "https://tell.dev" + CallbackPath,
|
||||
AfterLoginURL: "/",
|
||||
AfterLogoutURL: "/",
|
||||
LoginPath: LoginPath,
|
||||
CookieName: SessionCookie,
|
||||
CookieSecure: true,
|
||||
HTTPClient: httpClient,
|
||||
})
|
||||
|
||||
return &Auth{
|
||||
defaultAuth: defaultAuth,
|
||||
urpiAuth: urpiAuth,
|
||||
devAuth: devAuth,
|
||||
baseURL: baseURL,
|
||||
urpiBase: urpiBase,
|
||||
devBase: devBase,
|
||||
httpClient: httpClient,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *Auth) forRequest(r *http.Request) (*goloose.BrowserAuth, string) {
|
||||
if r != nil {
|
||||
host := strings.ToLower(r.Host)
|
||||
if fwd := r.Header.Get("X-Forwarded-Host"); fwd != "" {
|
||||
host = strings.ToLower(fwd)
|
||||
}
|
||||
if strings.Contains(host, "urpi.be") && a.urpiAuth != nil {
|
||||
return a.urpiAuth, a.urpiBase
|
||||
}
|
||||
if (strings.Contains(host, "dev") || strings.Contains(host, "localhost")) && a.devAuth != nil {
|
||||
return a.devAuth, a.devBase
|
||||
}
|
||||
}
|
||||
if a.defaultAuth != nil {
|
||||
return a.defaultAuth, a.baseURL
|
||||
}
|
||||
if a.devAuth != nil {
|
||||
return a.devAuth, a.devBase
|
||||
}
|
||||
return a.urpiAuth, a.urpiBase
|
||||
}
|
||||
|
||||
// Tenant reports the Go Loose tenant that owns the login.
|
||||
func (a *Auth) Tenant() string { return a.baseURL }
|
||||
|
||||
func (a *Auth) LoginHandler(w http.ResponseWriter, r *http.Request) {
|
||||
auth, _ := a.forRequest(r)
|
||||
if auth != nil {
|
||||
auth.LoginHandler(w, r)
|
||||
return
|
||||
}
|
||||
http.Error(w, "auth provider unavailable", http.StatusServiceUnavailable)
|
||||
}
|
||||
|
||||
func (a *Auth) CallbackHandler(w http.ResponseWriter, r *http.Request) {
|
||||
auth, _ := a.forRequest(r)
|
||||
if auth != nil {
|
||||
auth.CallbackHandler(w, r)
|
||||
return
|
||||
}
|
||||
http.Error(w, "auth provider unavailable", http.StatusServiceUnavailable)
|
||||
}
|
||||
|
||||
func (a *Auth) LogoutHandler(w http.ResponseWriter, r *http.Request) {
|
||||
auth, _ := a.forRequest(r)
|
||||
if auth != nil {
|
||||
auth.LogoutHandler(w, r)
|
||||
return
|
||||
}
|
||||
http.Error(w, "auth provider unavailable", http.StatusServiceUnavailable)
|
||||
}
|
||||
|
||||
// SessionUser resolves the Go Loose session cookie of a request into a user.
|
||||
// Requests without a cookie, with a rejected session, or without a reachable
|
||||
// identity provider are simply not signed in.
|
||||
|
|
@ -96,11 +192,34 @@ func (a *Auth) SessionUser(r *http.Request) (goloose.User, bool) {
|
|||
if err != nil || cookie.Value == "" {
|
||||
return goloose.User{}, false
|
||||
}
|
||||
request, err := http.NewRequestWithContext(r.Context(), http.MethodGet, a.baseURL+"/connect/userinfo", nil)
|
||||
_, targetBase := a.forRequest(r)
|
||||
if user, ok := a.checkUser(r.Context(), targetBase, cookie.Value); ok {
|
||||
return user, true
|
||||
}
|
||||
if targetBase != a.baseURL && a.baseURL != "" {
|
||||
if user, ok := a.checkUser(r.Context(), a.baseURL, cookie.Value); ok {
|
||||
return user, true
|
||||
}
|
||||
}
|
||||
if targetBase != a.urpiBase && a.urpiBase != "" {
|
||||
if user, ok := a.checkUser(r.Context(), a.urpiBase, cookie.Value); ok {
|
||||
return user, true
|
||||
}
|
||||
}
|
||||
if targetBase != a.devBase && a.devBase != "" {
|
||||
if user, ok := a.checkUser(r.Context(), a.devBase, cookie.Value); ok {
|
||||
return user, true
|
||||
}
|
||||
}
|
||||
return goloose.User{}, false
|
||||
}
|
||||
|
||||
func (a *Auth) checkUser(ctx context.Context, baseURL, token string) (goloose.User, bool) {
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/connect/userinfo", nil)
|
||||
if err != nil {
|
||||
return goloose.User{}, false
|
||||
}
|
||||
request.Header.Set("Authorization", "Bearer "+cookie.Value)
|
||||
request.Header.Set("Authorization", "Bearer "+token)
|
||||
response, err := a.httpClient.Do(request)
|
||||
if err != nil {
|
||||
return goloose.User{}, false
|
||||
|
|
|
|||
|
|
@ -146,5 +146,5 @@ func testAuth(t *testing.T, identity *httptest.Server) *Auth {
|
|||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &Auth{BrowserAuth: browser, baseURL: identity.URL, httpClient: identity.Client()}
|
||||
return &Auth{defaultAuth: browser, baseURL: identity.URL, httpClient: identity.Client()}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import (
|
|||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
|
|
@ -209,14 +210,11 @@ func (s *ContentService) ListBadges(context.Context) ([]Badge, error) {
|
|||
// Get returns one article by slug.
|
||||
func (s *ContentService) Get(_ context.Context, slug string) (Content, error) {
|
||||
slug = strings.ToLower(strings.TrimSpace(slug))
|
||||
if !validSlug(slug) {
|
||||
return Content{}, errors.New("invalid slug")
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
for _, item := range s.items {
|
||||
if item.Slug == slug {
|
||||
if item.Slug == slug || fmt.Sprint(item.ID) == slug {
|
||||
return cloneContent(item), nil
|
||||
}
|
||||
}
|
||||
|
|
@ -235,7 +233,7 @@ func (s *ContentService) UpdateAs(_ context.Context, c Content, actor string) (C
|
|||
|
||||
func (s *ContentService) update(c Content, actor string) (Content, error) {
|
||||
c.Slug = strings.ToLower(strings.TrimSpace(c.Slug))
|
||||
if !validSlug(c.Slug) || strings.TrimSpace(c.Title) == "" || strings.TrimSpace(c.Body) == "" {
|
||||
if strings.TrimSpace(c.Title) == "" || strings.TrimSpace(c.Body) == "" {
|
||||
return Content{}, errors.New("invalid content")
|
||||
}
|
||||
c.Badges = normalizeBadges(c.Badges)
|
||||
|
|
@ -247,7 +245,7 @@ func (s *ContentService) update(c Content, actor string) (Content, error) {
|
|||
|
||||
index := -1
|
||||
for i := range s.items {
|
||||
if s.items[i].Slug == c.Slug {
|
||||
if s.items[i].Slug == c.Slug || fmt.Sprint(s.items[i].ID) == c.Slug {
|
||||
index = i
|
||||
break
|
||||
}
|
||||
|
|
@ -262,8 +260,14 @@ func (s *ContentService) update(c Content, actor string) (Content, error) {
|
|||
c.ID = current.ID
|
||||
c.CreatedAt = current.CreatedAt
|
||||
c.UpdatedAt = time.Now().Truncate(time.Second)
|
||||
c.Category = normalizeCategory(current.Category)
|
||||
c.Author = current.Author
|
||||
if c.Category == "" {
|
||||
c.Category = normalizeCategory(current.Category)
|
||||
} else {
|
||||
c.Category = normalizeCategory(c.Category)
|
||||
}
|
||||
if c.Author == "" {
|
||||
c.Author = current.Author
|
||||
}
|
||||
if c.Status == "published" && c.PublishedAt == nil {
|
||||
now := time.Now().Truncate(time.Second)
|
||||
c.PublishedAt = &now
|
||||
|
|
@ -294,7 +298,10 @@ func (s *ContentService) Create(_ context.Context, c Content) (Content, error) {
|
|||
}
|
||||
c.Slug = strings.ToLower(strings.TrimSpace(c.Slug))
|
||||
if !validSlug(c.Slug) {
|
||||
return Content{}, errors.New("invalid slug")
|
||||
c.Slug = generateSlug(c.Title)
|
||||
}
|
||||
if !validSlug(c.Slug) {
|
||||
c.Slug = "post"
|
||||
}
|
||||
c.Category = normalizeCategory(c.Category)
|
||||
c.Badges = normalizeBadges(c.Badges)
|
||||
|
|
@ -327,8 +334,8 @@ func (s *ContentService) Create(_ context.Context, c Content) (Content, error) {
|
|||
s.nextID++
|
||||
for _, it := range s.items {
|
||||
if it.Slug == c.Slug {
|
||||
s.mu.Unlock()
|
||||
return Content{}, errors.New("slug already exists")
|
||||
c.Slug = fmt.Sprintf("%s-%d", c.Slug, c.ID)
|
||||
break
|
||||
}
|
||||
}
|
||||
created := cloneContent(c)
|
||||
|
|
@ -344,13 +351,10 @@ func (s *ContentService) Create(_ context.Context, c Content) (Content, error) {
|
|||
|
||||
func (s *ContentService) Delete(_ context.Context, slug string) error {
|
||||
slug = strings.ToLower(strings.TrimSpace(slug))
|
||||
if !validSlug(slug) {
|
||||
return errors.New("invalid slug")
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for i, it := range s.items {
|
||||
if it.Slug == slug {
|
||||
if it.Slug == slug || fmt.Sprint(it.ID) == slug {
|
||||
s.items = append(s.items[:i], s.items[i+1:]...)
|
||||
snapshot, _ := json.Marshal(it)
|
||||
s.history = append(s.history, contentRevision{ContentID: it.ID, Operation: "deleted", CreatedAt: time.Now(), Snapshot: string(snapshot)})
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package web
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
|
@ -25,6 +26,18 @@ type Handler struct {
|
|||
sso security.ITokenVerifier
|
||||
browser *golooseauth.Auth
|
||||
demo *service.DemoAuth
|
||||
// themeOrigin is the go-bananas origin supplying the shared styling contract.
|
||||
// Empty means the shell keeps its own fallbacks, which is the default.
|
||||
themeOrigin string
|
||||
}
|
||||
|
||||
// WithThemeOrigin sets the origin the shared styling contract is served from.
|
||||
//
|
||||
// A setter rather than a constructor argument, so that adding this does not change
|
||||
// the signature every existing caller and test passes through.
|
||||
func (h *Handler) WithThemeOrigin(origin string) *Handler {
|
||||
h.themeOrigin = origin
|
||||
return h
|
||||
}
|
||||
|
||||
func NewHandler(content service.IContentService, assets fs.FS, sso security.ITokenVerifier, browser *golooseauth.Auth, demo ...*service.DemoAuth) *Handler {
|
||||
|
|
@ -47,11 +60,13 @@ func (h *Handler) Routes(origins string) http.Handler {
|
|||
mux.HandleFunc("/api/auth/sudo", h.sudo)
|
||||
mux.HandleFunc("/api/auth/users", h.users)
|
||||
mux.HandleFunc("/api/protected", h.protected)
|
||||
mux.HandleFunc("/api/test/protected", h.protected)
|
||||
mux.HandleFunc("/api/content", h.contentList)
|
||||
mux.HandleFunc("/api/content", h.contentCollection)
|
||||
mux.HandleFunc("/api/dev/null", h.contentCollection)
|
||||
mux.HandleFunc("/api/content/categories", h.contentCategories)
|
||||
mux.HandleFunc("/api/content/badges", h.contentBadges)
|
||||
mux.HandleFunc("/api/content/clear", h.adminReset)
|
||||
mux.HandleFunc("/api/content/", h.contentDetail)
|
||||
mux.HandleFunc("/api/dev/null/", h.contentDetail)
|
||||
mux.HandleFunc("/api/admin/reset", h.adminReset)
|
||||
mux.HandleFunc("/", h.singlePageApp)
|
||||
return cors(mux, origins, h.sso.HeaderName())
|
||||
|
|
@ -67,6 +82,18 @@ func (h *Handler) session(w http.ResponseWriter, r *http.Request) {
|
|||
response["email"] = user.Email
|
||||
response["displayName"] = user.DisplayName
|
||||
}
|
||||
if h.demo != nil {
|
||||
response["demo"] = true
|
||||
if authenticated {
|
||||
for _, user := range h.demo.Users() {
|
||||
if user.Username == username {
|
||||
response["displayName"] = user.Name
|
||||
response["email"] = user.Username
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if h.browser != nil {
|
||||
response["loginUrl"] = golooseauth.LoginPath
|
||||
response["logoutUrl"] = golooseauth.LogoutPath
|
||||
|
|
@ -141,6 +168,17 @@ func (h *Handler) sudo(w http.ResponseWriter, r *http.Request) {
|
|||
jsonWrite(w, http.StatusOK, map[string]any{"token": token, "user": user})
|
||||
}
|
||||
|
||||
func (h *Handler) contentCollection(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
h.contentList(w, r)
|
||||
case http.MethodPost:
|
||||
h.contentCreate(w, r)
|
||||
default:
|
||||
jsonWrite(w, http.StatusMethodNotAllowed, nil)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) contentList(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
jsonWrite(w, http.StatusMethodNotAllowed, nil)
|
||||
|
|
@ -170,7 +208,7 @@ func (h *Handler) contentCreate(w http.ResponseWriter, r *http.Request) {
|
|||
jsonWrite(w, http.StatusMethodNotAllowed, nil)
|
||||
return
|
||||
}
|
||||
_, authenticated := h.identity(r)
|
||||
username, authenticated := h.identity(r)
|
||||
if !authenticated {
|
||||
jsonWrite(w, http.StatusUnauthorized, map[string]string{"error": "authentication required"})
|
||||
return
|
||||
|
|
@ -180,6 +218,9 @@ func (h *Handler) contentCreate(w http.ResponseWriter, r *http.Request) {
|
|||
jsonWrite(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(item.Author) == "" {
|
||||
item.Author = username
|
||||
}
|
||||
created, err := h.content.Create(r.Context(), item)
|
||||
if err != nil {
|
||||
jsonWrite(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
|
|
@ -216,6 +257,7 @@ func (h *Handler) contentBadges(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
func (h *Handler) contentDetail(w http.ResponseWriter, r *http.Request) {
|
||||
slug := strings.TrimPrefix(r.URL.Path, "/api/content/")
|
||||
slug = strings.TrimPrefix(slug, "/api/dev/null/")
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
item, err := h.content.Get(r.Context(), slug)
|
||||
|
|
@ -342,9 +384,52 @@ func (h *Handler) singlePageApp(w http.ResponseWriter, r *http.Request) {
|
|||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
body := applyThemeOrigin(index, h.themeOrigin)
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
_, _ = w.Write(index)
|
||||
_, _ = w.Write(body)
|
||||
}
|
||||
|
||||
// applyThemeOrigin adds the shared styling contract to the shell when an origin is
|
||||
// configured, and adds nothing when one is not.
|
||||
//
|
||||
// Appending rather than substituting, which is the opposite of the obvious approach
|
||||
// and the reason it is worth a comment. A placeholder in index.html has to survive
|
||||
// two things that both fail quietly:
|
||||
//
|
||||
// - The bundler resolves a <script src> at build time. A placeholder is not a
|
||||
// resolvable module, so the build fails with an error that points at the HTML
|
||||
// rather than at the cause.
|
||||
// - An unset or whitespace-only environment variable substitutes an empty or blank
|
||||
// origin, producing a relative URL. The browser then requests this application's
|
||||
// own origin, where neither path exists, and every page load logs a 404 that
|
||||
// reads as a styling bug in the wrong service.
|
||||
//
|
||||
// Adding nothing in the unset case is correct rather than degraded: the stylesheet's
|
||||
// every colour is written as var(--bn-role, <the colour it used to be>), so the
|
||||
// application renders exactly as it always did.
|
||||
func applyThemeOrigin(index []byte, base string) []byte {
|
||||
origin := strings.TrimRight(strings.TrimSpace(base), "/")
|
||||
if origin == "" {
|
||||
return index
|
||||
}
|
||||
|
||||
// Injected just before </head> so the stylesheet is parsed before the body
|
||||
// renders: a contract that arrives late produces a flash of the fallback palette.
|
||||
tag := "\n " +
|
||||
`<link rel="stylesheet" href="` + origin + `/rt/v1/contract.css" data-theme-contract>` + "\n " +
|
||||
`<script type="module" src="` + origin + `/rt/v1/components.js"></script>` + "\n"
|
||||
|
||||
head := []byte("</head>")
|
||||
at := bytes.Index(index, head)
|
||||
if at < 0 {
|
||||
return index
|
||||
}
|
||||
out := make([]byte, 0, len(index)+len(tag))
|
||||
out = append(out, index[:at]...)
|
||||
out = append(out, tag...)
|
||||
out = append(out, index[at:]...)
|
||||
return out
|
||||
}
|
||||
|
||||
func decode(r *http.Request, v any) bool { return json.NewDecoder(r.Body).Decode(v) == nil }
|
||||
|
|
@ -366,7 +451,7 @@ func cors(next http.Handler, origin, ssoHeader string) http.Handler {
|
|||
w.Header().Set("Access-Control-Allow-Origin", origin)
|
||||
w.Header().Set("Vary", "Origin")
|
||||
w.Header().Set("Access-Control-Allow-Headers", allowed)
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
|
|
|
|||
|
|
@ -32,6 +32,15 @@ func (f *fakeContent) Update(_ context.Context, c service.Content) (service.Cont
|
|||
f.updated = true
|
||||
return c, nil
|
||||
}
|
||||
func (f *fakeContent) Create(_ context.Context, c service.Content) (service.Content, error) {
|
||||
return c, nil
|
||||
}
|
||||
func (f *fakeContent) Delete(_ context.Context, _ string) error {
|
||||
return nil
|
||||
}
|
||||
func (f *fakeContent) Reset(_ context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func newTestRoutes(content *fakeContent, browser ...*golooseauth.Auth) http.Handler {
|
||||
assets := fstest.MapFS{
|
||||
|
|
@ -171,4 +180,48 @@ func TestServesEmbeddedAppForClientRoutes(t *testing.T) {
|
|||
if page.Code != http.StatusOK || !strings.Contains(page.Body.String(), `id="root"`) {
|
||||
t.Fatalf("spa fallback status = %d body = %q", page.Code, page.Body.String())
|
||||
}
|
||||
|
||||
devNull := httptest.NewRecorder()
|
||||
routes.ServeHTTP(devNull, httptest.NewRequest(http.MethodGet, "/dev/null", nil))
|
||||
if devNull.Code != http.StatusOK || !strings.Contains(devNull.Body.String(), `id="root"`) {
|
||||
t.Fatalf("dev/null fallback status = %d body = %q", devNull.Code, devNull.Body.String())
|
||||
}
|
||||
|
||||
backlogRc := httptest.NewRecorder()
|
||||
routes.ServeHTTP(backlogRc, httptest.NewRequest(http.MethodGet, "/etc/backlog.rc", nil))
|
||||
if backlogRc.Code != http.StatusOK || !strings.Contains(backlogRc.Body.String(), `id="root"`) {
|
||||
t.Fatalf("backlog.rc fallback status = %d body = %q", backlogRc.Code, backlogRc.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestContentCRUDAndClear(t *testing.T) {
|
||||
content := &fakeContent{}
|
||||
routes := newTestRoutes(content)
|
||||
|
||||
// Create with SSO header
|
||||
postReq := httptest.NewRequest(http.MethodPost, "/api/content", strings.NewReader(`{"title":"New Post","body":"Content body"}`))
|
||||
postReq.Header.Set("X-SSO-Token", "valid")
|
||||
postRec := httptest.NewRecorder()
|
||||
routes.ServeHTTP(postRec, postReq)
|
||||
if postRec.Code != http.StatusCreated {
|
||||
t.Fatalf("create status = %d, want %d", postRec.Code, http.StatusCreated)
|
||||
}
|
||||
|
||||
// Delete with SSO header
|
||||
delReq := httptest.NewRequest(http.MethodDelete, "/api/content/post", nil)
|
||||
delReq.Header.Set("X-SSO-Token", "valid")
|
||||
delRec := httptest.NewRecorder()
|
||||
routes.ServeHTTP(delRec, delReq)
|
||||
if delRec.Code != http.StatusNoContent {
|
||||
t.Fatalf("delete status = %d, want %d", delRec.Code, http.StatusNoContent)
|
||||
}
|
||||
|
||||
// Reset with SSO header
|
||||
resetReq := httptest.NewRequest(http.MethodPost, "/api/admin/reset", nil)
|
||||
resetReq.Header.Set("X-SSO-Token", "valid")
|
||||
resetRec := httptest.NewRecorder()
|
||||
routes.ServeHTTP(resetRec, resetReq)
|
||||
if resetRec.Code != http.StatusOK {
|
||||
t.Fatalf("reset status = %d, want %d", resetRec.Code, http.StatusOK)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
75
internal/web/theme_origin_test.go
Normal file
75
internal/web/theme_origin_test.go
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
package web
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const shell = `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>Urpi's backlog</title>
|
||||
</head>
|
||||
<body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body>
|
||||
</html>`
|
||||
|
||||
// TestThemeLinesAreAddedWhenConfigured is the whole feature: the contract reaches
|
||||
// the page.
|
||||
func TestThemeLinesAreAddedWhenConfigured(t *testing.T) {
|
||||
got := string(applyThemeOrigin([]byte(shell), "https://bananas-dev.urpi.be"))
|
||||
|
||||
if !strings.Contains(got, `href="https://bananas-dev.urpi.be/rt/v1/contract.css"`) {
|
||||
t.Errorf("the contract stylesheet was not added:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, `src="https://bananas-dev.urpi.be/rt/v1/components.js"`) {
|
||||
t.Errorf("the control module was not added:\n%s", got)
|
||||
}
|
||||
// Injected before </head>, so the stylesheet is parsed before the body renders. A
|
||||
// contract that arrives late shows a flash of the fallback palette.
|
||||
headClose := strings.Index(got, "</head>")
|
||||
link := strings.Index(got, "contract.css")
|
||||
if link < 0 || link > headClose {
|
||||
t.Errorf("the contract link is not inside the head (link=%d, head=%d)", link, headClose)
|
||||
}
|
||||
// The application script must survive untouched.
|
||||
if !strings.Contains(got, `src="/src/main.tsx"`) {
|
||||
t.Error("the application script was altered")
|
||||
}
|
||||
}
|
||||
|
||||
func TestThemeOriginTrailingSlashIsTrimmed(t *testing.T) {
|
||||
for _, base := range []string{
|
||||
"https://bananas-dev.urpi.be/",
|
||||
"https://bananas-dev.urpi.be///",
|
||||
} {
|
||||
got := string(applyThemeOrigin([]byte(shell), base))
|
||||
if strings.Contains(got, "be//") {
|
||||
t.Errorf("base %q produced a doubled slash:\n%s", base, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestNothingIsAddedWhenNoOriginIsConfigured is the branch that matters most: an
|
||||
// unset or blank origin must leave the page byte-identical, because the stylesheet's
|
||||
// var() fallbacks are what carry the application in that case.
|
||||
func TestNothingIsAddedWhenNoOriginIsConfigured(t *testing.T) {
|
||||
for _, base := range []string{"", " ", "\t", "/"} {
|
||||
got := applyThemeOrigin([]byte(shell), base)
|
||||
if string(got) != shell {
|
||||
t.Errorf("base %q modified the shell:\n%s", base, got)
|
||||
}
|
||||
if strings.Contains(string(got), "rt/v1") {
|
||||
t.Errorf("base %q left a contract reference behind:\n%s", base, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestShellWithoutHeadIsUnchanged covers a malformed shell. Returning the input
|
||||
// unchanged is better than corrupting it, and better than a 500 on a page that would
|
||||
// otherwise have rendered.
|
||||
func TestShellWithoutHeadIsUnchanged(t *testing.T) {
|
||||
odd := []byte("<html><body>no head</body></html>")
|
||||
if got := applyThemeOrigin(odd, "https://bananas-dev.urpi.be"); string(got) != string(odd) {
|
||||
t.Errorf("a shell with no head was modified:\n%s", got)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue