test(14.2.1-04): add ML, markdown, resolver, and SPA test matrices
Prove nested ML writes, unsafe markdown rejection, request-locale isolation, and generated FormView types. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
75
admin/tests/form/MarkdownField.test.ts
Normal file
75
admin/tests/form/MarkdownField.test.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||||
|
import { enableAutoUnmount, mount } from '@vue/test-utils'
|
||||||
|
import type { FormField } from '../../src/api/types'
|
||||||
|
import MarkdownField from '../../src/components/form/fields/MarkdownField.vue'
|
||||||
|
import MLMarkdownField from '../../src/components/form/fields/MLMarkdownField.vue'
|
||||||
|
import { resetState } from '../helpers'
|
||||||
|
|
||||||
|
function field(type = 'markdown', name = 'body'): FormField {
|
||||||
|
return { name, label: 'Body', type } as FormField
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetState()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
document.body.innerHTML = ''
|
||||||
|
})
|
||||||
|
|
||||||
|
enableAutoUnmount(afterEach)
|
||||||
|
|
||||||
|
describe('MarkdownField composition and sinks', () => {
|
||||||
|
it('edits source and previews interpolated text without v-html', async () => {
|
||||||
|
const wrapper = mount(MarkdownField, {
|
||||||
|
props: {
|
||||||
|
field: field(),
|
||||||
|
modelValue: '# Hello',
|
||||||
|
controlId: 'f-body',
|
||||||
|
},
|
||||||
|
attachTo: document.body,
|
||||||
|
})
|
||||||
|
expect(wrapper.find('textarea').element.value).toBe('# Hello')
|
||||||
|
await wrapper.find('textarea').setValue('# Changed')
|
||||||
|
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['# Changed'])
|
||||||
|
await wrapper.setProps({ modelValue: '# Changed' })
|
||||||
|
await wrapper.find('[data-markdown-preview]').trigger('click')
|
||||||
|
expect(wrapper.find('[data-markdown-preview-pane]').text()).toBe('# Changed')
|
||||||
|
expect(wrapper.find('h1').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not execute script, iframe, or event-handler HTML', async () => {
|
||||||
|
const wrapper = mount(MarkdownField, {
|
||||||
|
props: {
|
||||||
|
field: field(),
|
||||||
|
modelValue:
|
||||||
|
'<script>window.__md_xss = 1</script><iframe src="javascript:alert(1)"></iframe><img src=x onerror="window.__md_xss = 1">',
|
||||||
|
controlId: 'f-body',
|
||||||
|
},
|
||||||
|
attachTo: document.body,
|
||||||
|
})
|
||||||
|
await wrapper.find('[data-markdown-preview]').trigger('click')
|
||||||
|
expect(wrapper.find('script').exists()).toBe(false)
|
||||||
|
expect(wrapper.find('iframe').exists()).toBe(false)
|
||||||
|
expect(wrapper.find('img').exists()).toBe(false)
|
||||||
|
expect((window as unknown as { __md_xss?: number }).__md_xss).toBeUndefined()
|
||||||
|
expect(wrapper.html()).not.toMatch(/v-html|innerHTML/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('composes inside mlmarkdown without a raw-HTML sink', async () => {
|
||||||
|
const wrapper = mount(MLMarkdownField, {
|
||||||
|
props: {
|
||||||
|
field: field('mlmarkdown', 'body'),
|
||||||
|
modelValue: { en: '# Hi', pl: '<script>window.__md_xss = 1</script>' },
|
||||||
|
controlId: 'f-body',
|
||||||
|
},
|
||||||
|
attachTo: document.body,
|
||||||
|
})
|
||||||
|
expect(wrapper.findComponent(MarkdownField).exists()).toBe(true)
|
||||||
|
await wrapper.find('[data-ml-locale]').setValue('pl')
|
||||||
|
await wrapper.find('[data-markdown-preview]').trigger('click')
|
||||||
|
expect(wrapper.find('script').exists()).toBe(false)
|
||||||
|
expect((window as unknown as { __md_xss?: number }).__md_xss).toBeUndefined()
|
||||||
|
expect(wrapper.find('[data-markdown-preview-pane]').text()).toContain('<script>')
|
||||||
|
})
|
||||||
|
})
|
||||||
40
modules/cabana/markdown_test.go
Normal file
40
modules/cabana/markdown_test.go
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
package cabana
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMarkdownRejectsUnsafeHTML(t *testing.T) {
|
||||||
|
if html, err := RenderMarkdown("# Hello"); err != nil || !strings.Contains(html, "<h1>") {
|
||||||
|
t.Fatalf("safe markdown: html=%s err=%v", html, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
src string
|
||||||
|
needle string
|
||||||
|
}{
|
||||||
|
{"script", "<script>alert(1)</script>", "<script"},
|
||||||
|
{"iframe", `<iframe src="https://evil.test"></iframe>`, "<iframe"},
|
||||||
|
{"event", `<img src=x onerror="alert(1)">`, "onerror"},
|
||||||
|
{"javascript", "[x](javascript:alert(1))", "javascript:"},
|
||||||
|
{"vbscript", "[x](vbscript:msgbox(1))", "vbscript:"},
|
||||||
|
{"data", "[x](data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==)", "data:"},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
html, err := RenderMarkdown(tc.src)
|
||||||
|
if err == nil && strings.Contains(strings.ToLower(html), tc.needle) {
|
||||||
|
t.Fatalf("unsafe HTML survived: %s", html)
|
||||||
|
}
|
||||||
|
unchecked, convErr := renderMarkdownUnchecked(tc.src)
|
||||||
|
if convErr != nil {
|
||||||
|
t.Fatal(convErr)
|
||||||
|
}
|
||||||
|
if strings.Contains(strings.ToLower(unchecked), tc.needle) && err == nil {
|
||||||
|
t.Fatalf("RenderMarkdown accepted %s: %s", tc.name, html)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
191
modules/cabana/ml_test.go
Normal file
191
modules/cabana/ml_test.go
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
package cabana
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/summercms/modules/pact"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMLFieldTypes(t *testing.T) {
|
||||||
|
accepted := `fields:
|
||||||
|
title:
|
||||||
|
type: mltext
|
||||||
|
label: Title
|
||||||
|
excerpt:
|
||||||
|
type: markdown
|
||||||
|
label: Excerpt
|
||||||
|
body:
|
||||||
|
type: mlmarkdown
|
||||||
|
label: Body
|
||||||
|
`
|
||||||
|
schema, err := CompileForm("acme.demo", schemaController{model: "Widget"}, formFS(formConfig, accepted))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("compile accepted types: %v", err)
|
||||||
|
}
|
||||||
|
got := map[string]string{}
|
||||||
|
for _, field := range schema.Fields {
|
||||||
|
got[field.Name] = field.Type
|
||||||
|
}
|
||||||
|
if got["title"] != "mltext" || got["excerpt"] != "markdown" || got["body"] != "mlmarkdown" {
|
||||||
|
t.Fatalf("types = %v", got)
|
||||||
|
}
|
||||||
|
_, err = CompileForm("acme.demo", schemaController{model: "Widget"}, formFS(formConfig, "fields:\n title:\n type: mlunknown\n label: Title\n"))
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "unsupported type mlunknown") {
|
||||||
|
t.Fatalf("unknown type err = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMLNestedSave(t *testing.T) {
|
||||||
|
_, db := newListService(t)
|
||||||
|
if err := db.Migrator().DropTable(&mlPost{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := db.AutoMigrate(&mlPost{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
writer := &recordingWriter{defaultLocale: "en", enabled: []string{"en", "pl"}}
|
||||||
|
svc := CRUDService{DB: db, writer: writer}
|
||||||
|
cc := mlCompiled(t)
|
||||||
|
|
||||||
|
projected := ProjectWritableFields(cc, map[string]any{
|
||||||
|
"title": map[string]any{"en": "Hello", "pl": "Witaj"},
|
||||||
|
"extra": map[string]any{"nested": true},
|
||||||
|
})
|
||||||
|
if len(projected) != 0 {
|
||||||
|
t.Fatalf("unlifted nested maps reached projection: %#v", projected)
|
||||||
|
}
|
||||||
|
|
||||||
|
rec, err := svc.Create(context.Background(), cc, RecordInput{Body: map[string]any{
|
||||||
|
"title": map[string]any{"en": "Hello", "pl": "Witaj"},
|
||||||
|
"extra": map[string]any{"nested": true},
|
||||||
|
}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create: %v", err)
|
||||||
|
}
|
||||||
|
if rec["title"] != "Hello" {
|
||||||
|
t.Fatalf("projected title = %#v", rec["title"])
|
||||||
|
}
|
||||||
|
var row mlPost
|
||||||
|
if err := db.First(&row).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if row.Title != "Hello" {
|
||||||
|
t.Fatalf("host title = %q", row.Title)
|
||||||
|
}
|
||||||
|
if writer.attrs["en"] != nil {
|
||||||
|
t.Fatalf("default locale duplicated: %#v", writer.attrs["en"])
|
||||||
|
}
|
||||||
|
if writer.attrs["pl"]["title"] != "Witaj" {
|
||||||
|
t.Fatalf("Polish attributes = %#v", writer.attrs)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = svc.Create(context.Background(), cc, RecordInput{Body: map[string]any{
|
||||||
|
"title": map[string]any{"en": "Hello", "de": "Hallo"},
|
||||||
|
}})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("undeclared locale succeeded")
|
||||||
|
}
|
||||||
|
_, err = svc.Create(context.Background(), cc, RecordInput{Body: map[string]any{
|
||||||
|
"title": map[string]any{"en": 1, "pl": "x"},
|
||||||
|
}})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("non-string locale value succeeded")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestML(t *testing.T) {
|
||||||
|
t.Run("writer failure rolls back host write", func(t *testing.T) {
|
||||||
|
_, db := newListService(t)
|
||||||
|
if err := db.Migrator().DropTable(&mlPost{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := db.AutoMigrate(&mlPost{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
writer := &boomWriter{recordingWriter: recordingWriter{defaultLocale: "en", enabled: []string{"en", "pl"}}}
|
||||||
|
svc := CRUDService{DB: db, writer: writer}
|
||||||
|
_, err := svc.Create(context.Background(), mlCompiled(t), RecordInput{Body: map[string]any{
|
||||||
|
"title": map[string]any{"en": "Hello", "pl": "Witaj"},
|
||||||
|
}})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("writer failure succeeded")
|
||||||
|
}
|
||||||
|
if writer.calls == 0 {
|
||||||
|
t.Fatal("writer was not invoked")
|
||||||
|
}
|
||||||
|
var n int64
|
||||||
|
if err := db.Model(&mlPost{}).Count(&n).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if n != 0 {
|
||||||
|
t.Fatalf("host rows after writer failure = %d", n)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("writer is not invoked before query checks", func(t *testing.T) {
|
||||||
|
_, db := newListService(t)
|
||||||
|
if err := db.Migrator().DropTable(&mlPost{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := db.AutoMigrate(&mlPost{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := db.Create(&mlPost{Title: "Hello"}).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
writer := &recordingWriter{defaultLocale: "en", enabled: []string{"en", "pl"}}
|
||||||
|
svc := CRUDService{DB: db, writer: writer}
|
||||||
|
cc := scopedMLCompiled(t)
|
||||||
|
_, err := svc.Update(context.Background(), cc, 1, RecordInput{Body: map[string]any{
|
||||||
|
"title": map[string]any{"en": "Hello", "pl": "Witaj"},
|
||||||
|
}})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("out-of-scope update succeeded")
|
||||||
|
}
|
||||||
|
if len(writer.ids) != 0 {
|
||||||
|
t.Fatalf("writer ran before query scope: %v", writer.ids)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type boomWriter struct {
|
||||||
|
recordingWriter
|
||||||
|
calls int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *boomWriter) WriteTranslated(ctx context.Context, tx *gorm.DB, model any, field, locale, value string) error {
|
||||||
|
w.calls++
|
||||||
|
return errors.New("writer failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
type scopedMLController struct{ mlController }
|
||||||
|
|
||||||
|
func (scopedMLController) FormExtendQuery(_ context.Context, q *gorm.DB) *gorm.DB {
|
||||||
|
return q.Where("1 = 0")
|
||||||
|
}
|
||||||
|
|
||||||
|
func scopedMLCompiled(t *testing.T) *CompiledController {
|
||||||
|
t.Helper()
|
||||||
|
reg, err := compileRegistry([]controllerRef{{
|
||||||
|
plugin: formPlugin{fsys: mlFS()},
|
||||||
|
ctl: scopedMLController{},
|
||||||
|
}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("registry: %v", err)
|
||||||
|
}
|
||||||
|
cc, ok := reg.Get("acme.demo.posts")
|
||||||
|
if !ok || cc.Form == nil {
|
||||||
|
t.Fatalf("compiled controller missing form: %+v", cc)
|
||||||
|
}
|
||||||
|
return cc
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
_ pact.AdminController = scopedMLController{}
|
||||||
|
_ pact.AdminRecordSource = scopedMLController{}
|
||||||
|
_ pact.FormExtendQuery = scopedMLController{}
|
||||||
|
)
|
||||||
@@ -75,6 +75,57 @@ func into[T any]() func(*json.Decoder) error {
|
|||||||
// each real response into the Go type its annotation documents with unknown
|
// each real response into the Go type its annotation documents with unknown
|
||||||
// fields disallowed, and checks admin/openapi/admin.json names that type for
|
// fields disallowed, and checks admin/openapi/admin.json names that type for
|
||||||
// the same path, method and status (D-15, D-16; Pitfall 8).
|
// the same path, method and status (D-15, D-16; Pitfall 8).
|
||||||
|
func TestMLOpenAPIConformance(t *testing.T) {
|
||||||
|
spec := conformSpec(t)
|
||||||
|
if got := spec.ref("/{vendor}/{plugin}/{controller}/schema/form", "get", "200"); got != "cabana.Envelope-cabana_FormView" {
|
||||||
|
t.Fatalf("form schema ref = %q", got)
|
||||||
|
}
|
||||||
|
_, file, _, ok := runtime.Caller(0)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("caller")
|
||||||
|
}
|
||||||
|
raw, err := os.ReadFile(filepath.Join(filepath.Dir(file), "..", "..", "admin", "openapi", "admin.json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
doc := string(raw)
|
||||||
|
for _, name := range []string{"cabana.FormField", "cabana.FormView"} {
|
||||||
|
if !strings.Contains(doc, `"`+name+`"`) {
|
||||||
|
t.Fatalf("admin.json missing %s", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
schema, err := cabana.CompileForm("acme.demo", stubMLSchemaController{}, fstest.MapFS{
|
||||||
|
"controllers/widgets/config_form.yaml": {Data: []byte("name: posts\nform: ~/plugins/acme/demo/models/widget/fields.yaml\nmodelClass: Widget\n")},
|
||||||
|
"models/widget/fields.yaml": {Data: []byte(`fields:
|
||||||
|
title:
|
||||||
|
type: mltext
|
||||||
|
label: Title
|
||||||
|
excerpt:
|
||||||
|
type: markdown
|
||||||
|
label: Excerpt
|
||||||
|
body:
|
||||||
|
type: mlmarkdown
|
||||||
|
label: Body
|
||||||
|
`)},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got := map[string]string{}
|
||||||
|
for _, field := range schema.Fields {
|
||||||
|
got[field.Name] = field.Type
|
||||||
|
}
|
||||||
|
if got["title"] != "mltext" || got["excerpt"] != "markdown" || got["body"] != "mlmarkdown" {
|
||||||
|
t.Fatalf("compiled types = %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type stubMLSchemaController struct{}
|
||||||
|
|
||||||
|
func (stubMLSchemaController) ID() string { return "acme.demo.widgets" }
|
||||||
|
func (stubMLSchemaController) ModelName() string { return "Widget" }
|
||||||
|
func (stubMLSchemaController) ConfigDir() string { return "controllers/widgets" }
|
||||||
|
|
||||||
func TestPhase10OpenAPIConformance(t *testing.T) {
|
func TestPhase10OpenAPIConformance(t *testing.T) {
|
||||||
env := newConformEnv(t)
|
env := newConformEnv(t)
|
||||||
spec := conformSpec(t)
|
spec := conformSpec(t)
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
package surf
|
package surf
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||||
@@ -59,6 +62,93 @@ func TestLocaleResolverWritesValidatedCodeAndStripsPrefix(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLocaleResolver(t *testing.T) {
|
||||||
|
t.Run("present writes validated code", TestLocaleResolverWritesValidatedCodeAndStripsPrefix)
|
||||||
|
t.Run("absent keeps Accept-Language", TestLocaleAbsentResolverKeepsAcceptLanguage)
|
||||||
|
t.Run("conflicting requests stay isolated", TestLocaleResolverRequestIsolation)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLocaleResolverRequestIsolation(t *testing.T) {
|
||||||
|
cfg := writeHTTPConfig(t, "body_limits:\n default_bytes: 1024\n upload_bytes: 1024\n")
|
||||||
|
app := backpack.New(cfg)
|
||||||
|
if err := app.Publish[LocaleResolver](pathLocaleResolver{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
r := New(nil)
|
||||||
|
r.app = app
|
||||||
|
r.defaultBytes = 1024
|
||||||
|
r.BindPlugin("acme.blog")
|
||||||
|
var mu sync.Mutex
|
||||||
|
got := map[string]string{}
|
||||||
|
r.Get("/posts", func(w http.ResponseWriter, req *http.Request) {
|
||||||
|
code, _ := towel.Locale(req.Context())
|
||||||
|
mu.Lock()
|
||||||
|
got[req.Header.Get("X-Req")] = code
|
||||||
|
mu.Unlock()
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
})
|
||||||
|
h, err := r.compile()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for i, code := range []string{"pl", "en", "pl", "en"} {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(i int, code string) {
|
||||||
|
defer wg.Done()
|
||||||
|
id := fmt.Sprintf("%s-%d", code, i)
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/"+code+"/posts", nil)
|
||||||
|
req.Header.Set("X-Req", id)
|
||||||
|
req.Header.Set("Accept-Language", "de")
|
||||||
|
h.ServeHTTP(httptest.NewRecorder(), req)
|
||||||
|
mu.Lock()
|
||||||
|
if got[id] != code {
|
||||||
|
t.Errorf("request %s locale = %q", id, got[id])
|
||||||
|
}
|
||||||
|
mu.Unlock()
|
||||||
|
}(i, code)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
type pathLocaleResolver struct{}
|
||||||
|
|
||||||
|
func (pathLocaleResolver) Resolve(_ http.ResponseWriter, r *http.Request) string {
|
||||||
|
if r == nil {
|
||||||
|
return "en"
|
||||||
|
}
|
||||||
|
if v := r.Context().Value(stubPrefixKey{}); v != nil {
|
||||||
|
if code, ok := v.(string); ok && code != "" {
|
||||||
|
return code
|
||||||
|
}
|
||||||
|
}
|
||||||
|
path := r.URL.Path
|
||||||
|
if len(path) >= 3 && path[0] == '/' && (path[1:3] == "pl" || path[1:3] == "en") {
|
||||||
|
return path[1:3]
|
||||||
|
}
|
||||||
|
return "en"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pathLocaleResolver) Rewrite(r *http.Request) *http.Request {
|
||||||
|
if r == nil {
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
path := r.URL.Path
|
||||||
|
if len(path) >= 3 && path[0] == '/' && (path[1:3] == "pl" || path[1:3] == "en") && (len(path) == 3 || path[3] == '/') {
|
||||||
|
code := path[1:3]
|
||||||
|
clone := r.Clone(context.WithValue(r.Context(), stubPrefixKey{}, code))
|
||||||
|
if len(path) == 3 {
|
||||||
|
clone.URL.Path = "/"
|
||||||
|
} else {
|
||||||
|
clone.URL.Path = path[3:]
|
||||||
|
}
|
||||||
|
return clone
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
type stubPrefixKey struct{}
|
||||||
|
|
||||||
func TestLocaleAbsentResolverKeepsAcceptLanguage(t *testing.T) {
|
func TestLocaleAbsentResolverKeepsAcceptLanguage(t *testing.T) {
|
||||||
r := New(nil)
|
r := New(nil)
|
||||||
r.BindPlugin("acme.blog")
|
r.BindPlugin("acme.blog")
|
||||||
|
|||||||
Reference in New Issue
Block a user