Skip to content

Commit 2e9d171

Browse files
committed
fix: restore target_language validation on RFC 7396 PATCH
Switching the PATCH field to Optional[string] dropped the struct-tag validation (no_null_bytes, max=35) — go-playground's validator can't reach through the custom type — so PATCH accepted null bytes and over-long values that PUT and the OpenAPI maxLength:35 still reject. Enforce the same bounds in the PATCH value path (normalizeProvidedTargetLanguage), restoring PUT/PATCH parity and honoring the contract. Also pin settingKeyTargetLanguage to the EnrichmentSettings json tag with a unit test, so a tag rename can't silently break PATCH null-removal. ENG-1254
1 parent c36b8a2 commit 2e9d171

3 files changed

Lines changed: 81 additions & 2 deletions

File tree

internal/service/tenant_settings_service.go

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"fmt"
66
"strings"
7+
"unicode/utf8"
78

89
"golang.org/x/text/language"
910

@@ -24,9 +25,15 @@ type TenantSettingsRepository interface {
2425

2526
// settingKeyTargetLanguage is the JSONB key for the target language. It must match
2627
// the json tag on models.EnrichmentSettings.TargetLanguage; it is the key removed
27-
// when a PATCH sends target_language as null.
28+
// when a PATCH sends target_language as null. TestSettingKeyMatchesModelTag pins
29+
// this to the tag so a rename can't silently break null-removal.
2830
const settingKeyTargetLanguage = "target_language"
2931

32+
// maxTargetLanguageLen bounds a provided target_language value. It mirrors the
33+
// `max=35` struct tag on UpdateTenantSettingsRequest (the PUT path) and the
34+
// OpenAPI maxLength, so PUT and PATCH enforce the same limit.
35+
const maxTargetLanguageLen = 35
36+
3037
// TenantSettingsService reads and writes tenant-scoped enrichment settings. It is
3138
// the accessor enrichment workflows will use to resolve a tenant's configuration.
3239
type TenantSettingsService struct {
@@ -149,13 +156,25 @@ func normalizeTargetLanguage(raw string) (string, error) {
149156
// normalizeProvidedTargetLanguage validates a non-null target_language supplied in
150157
// a PATCH. Unlike PUT, an empty value is rejected: under RFC 7396 the way to
151158
// remove a setting is JSON null, so an explicit "" is a malformed locale rather
152-
// than a clear.
159+
// than a clear. It also enforces the same null-byte and length bounds the PUT path
160+
// gets from struct tags, which the Optional[string] field cannot carry.
153161
func normalizeProvidedTargetLanguage(raw string) (string, error) {
154162
if strings.TrimSpace(raw) == "" {
155163
return "", huberrors.NewValidationError(
156164
"target_language",
157165
"target_language must be a valid BCP-47 locale (e.g. en-US); send null to remove it")
158166
}
159167

168+
if strings.ContainsRune(raw, '\x00') {
169+
return "", huberrors.NewValidationError(
170+
"target_language", "target_language must not contain null bytes")
171+
}
172+
173+
if utf8.RuneCountInString(raw) > maxTargetLanguageLen {
174+
return "", huberrors.NewValidationError(
175+
"target_language",
176+
fmt.Sprintf("target_language must be at most %d characters", maxTargetLanguageLen))
177+
}
178+
160179
return normalizeTargetLanguage(raw)
161180
}

internal/service/tenant_settings_service_test.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@ package service
22

33
import (
44
"context"
5+
"encoding/json"
56
"errors"
7+
"strings"
68
"testing"
79

810
"github.com/formbricks/hub/internal/huberrors"
@@ -401,3 +403,48 @@ func TestTenantSettingsService_PatchSettings_RepoErrorPropagates(t *testing.T) {
401403
t.Fatalf("PatchSettings() error = %v, want tenant write conflict", err)
402404
}
403405
}
406+
407+
func TestTenantSettingsService_PatchSettings_OversizedRejected(t *testing.T) {
408+
repo := &mockTenantSettingsRepo{}
409+
svc := NewTenantSettingsService(repo)
410+
411+
// Parity with PUT's max=35: a provided value longer than the bound is rejected
412+
// before it reaches the repo (Optional[string] can't carry the struct tag).
413+
_, err := svc.PatchSettings(context.Background(), "org-123", patchValue(strings.Repeat("a", maxTargetLanguageLen+1)))
414+
if !errors.Is(err, huberrors.ErrValidation) {
415+
t.Fatalf("PatchSettings() error = %v, want validation error for an oversized value", err)
416+
}
417+
418+
if repo.patchCalled {
419+
t.Fatal("repo.Patch called despite oversized value")
420+
}
421+
}
422+
423+
func TestTenantSettingsService_PatchSettings_NullByteRejected(t *testing.T) {
424+
repo := &mockTenantSettingsRepo{}
425+
svc := NewTenantSettingsService(repo)
426+
427+
_, err := svc.PatchSettings(context.Background(), "org-123", patchValue("en\x00US"))
428+
if !errors.Is(err, huberrors.ErrValidation) {
429+
t.Fatalf("PatchSettings() error = %v, want validation error for a null byte", err)
430+
}
431+
432+
if repo.patchCalled {
433+
t.Fatal("repo.Patch called despite null byte")
434+
}
435+
}
436+
437+
// TestSettingKeyMatchesModelTag pins settingKeyTargetLanguage to the json tag on
438+
// EnrichmentSettings.TargetLanguage, so a tag rename can't silently break PATCH
439+
// null-removal (which deletes by that key string).
440+
func TestSettingKeyMatchesModelTag(t *testing.T) {
441+
raw, err := json.Marshal(models.EnrichmentSettings{TargetLanguage: "en-US"})
442+
if err != nil {
443+
t.Fatalf("marshal: %v", err)
444+
}
445+
446+
if want := `"` + settingKeyTargetLanguage + `":`; !strings.Contains(string(raw), want) {
447+
t.Fatalf("settingKeyTargetLanguage %q is not the json key in %s — const and tag have drifted",
448+
settingKeyTargetLanguage, raw)
449+
}
450+
}

tests/tenant_settings_test.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -346,6 +346,19 @@ func TestTenantSettings_PatchEmptyStringRejected(t *testing.T) {
346346
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
347347
}
348348

349+
// TestTenantSettings_PatchRejectsOversizedLanguage mirrors the PUT bound: a value
350+
// over the 35-char limit is rejected (the limit lives in the service for PATCH,
351+
// since Optional[string] cannot carry the struct tag PUT uses).
352+
func TestTenantSettings_PatchRejectsOversizedLanguage(t *testing.T) {
353+
server, cleanup := setupTestServer(t)
354+
defer cleanup()
355+
356+
body := `{"target_language":"` + strings.Repeat("a", 40) + `"}`
357+
resp := settingsRequest(t, server.URL, http.MethodPatch, testTenantID("patch-oversized"), body, true)
358+
require.NoError(t, resp.Body.Close())
359+
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
360+
}
361+
349362
// TestTenantSettings_PatchMergePreservesOtherKeys proves PATCH does a JSONB merge
350363
// (||), not a full replace: a key the typed model does not know about survives.
351364
func TestTenantSettings_PatchMergePreservesOtherKeys(t *testing.T) {

0 commit comments

Comments
 (0)