Files
go-admin/app/other/apis/tools/sys_tables_validate_test.go
T
zhangwenjian 05661e2f3e fix🐛: jsonField format check relaxed to any legal identifier
jsonFieldPattern copied businessName's rule (^[a-z][A-Za-z]+$: at least
two letters, no digits) on the theory that jsonField should tighten to
the same identifier shape. That does not hold: businessName is typed
by a person on genInfoForm.vue, so a strict pattern is a reasonable
guardrail on human input. jsonField is computed by the importer from
the column name (sys_tables.go's namelist/JsonField loop) - nobody
types it, so the same pattern only rejected names the importer
legitimately produces: a single-letter column ("x") or one whose last
segment ends in a digit ("address2", "a1") both collapse to a single
camelCase word with nothing left to re-capitalize, and both failed the
old check.

The blast radius is wider than "this one column can't be edited":
validateAndSanitizeColumns runs over every column on every Update, so
a table that merely contains one such column could not save any
config change at all, including edits with nothing to do with that
column.

Relaxed to ^[a-z][A-Za-z0-9]*$ - any legal JS/TS identifier starting
with a lowercase letter. Still rejects what has to be rejected: empty,
whitespace/punctuation, and leading-digit names, since those cannot be
unquoted object keys in the generated interface/lang file at all.
Uniqueness and the expression-content check on defaultValue are
unchanged - defaultValue is genuinely user-typed (F6's config page),
so tightening it was the right call to begin with; this was the only
place a human-input rule had been copied onto machine-generated data.

Verified against the real import path, not hand-typed jsonField values:
built a table with columns id/x/address2/a1 in a fake information_schema,
ran it through the real SysTable.Insert, confirmed the importer computes
exactly jsonField x/address2/a1, then submitted an update through the
real SysTable.Update changing only tableComment (nothing about those
columns). Confirmed red first - 500, "jsonField 格式不合法:\"x\"" - a
change unrelated to any of the three columns was rejected solely because
they existed on the table. Restored the fix - 200, "修改成功".
2026-09-19 20:37:01 +08:00

158 lines
5.6 KiB
Go

package tools
import (
"testing"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"go-admin/app/other/models/tools"
)
func TestValidateAndSanitizeColumns_JsonFieldFormat(t *testing.T) {
cases := []struct {
name string
jsonField string
wantErr bool
}{
{"lower camelCase", "userName", false},
{"two-letter lowercase", "id", false},
// The importer's own output (sys_tables.go's namelist/JsonField
// loop), not made up: a single-letter column ("x"), and a column
// whose last name segment ends in a digit ("address2", "a1") both
// produce a jsonField with no separator left to re-capitalize.
// These three used to be rejected - the whole point of this fix.
{"single letter, real importer output for a column named x", "x", false},
{"letters then a trailing digit, real importer output for address2", "address2", false},
{"two letters then a digit, real importer output for a1", "a1", false},
{"leading underscore rejected", "_id", true},
{"leading digit rejected (not a legal identifier start)", "1name", true},
{"snake_case rejected (importer never emits an underscore)", "user_name", true},
{"dot rejected, would break the gen/{pkg}/{biz}.ts key path", "user.name", true},
{"empty rejected", "", true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := validateAndSanitizeColumns([]tools.SysColumns{{JsonField: tc.jsonField}})
if tc.wantErr && err == nil {
t.Errorf("jsonField %q: want error, got nil", tc.jsonField)
}
if !tc.wantErr && err != nil {
t.Errorf("jsonField %q: want no error, got %v", tc.jsonField, err)
}
})
}
}
func TestValidateAndSanitizeColumns_JsonFieldUniqueWithinTable(t *testing.T) {
err := validateAndSanitizeColumns([]tools.SysColumns{
{JsonField: "name"},
{JsonField: "name"},
})
if err == nil {
t.Fatal("want error for a jsonField repeated in the same table, got nil")
}
}
func TestValidateAndSanitizeColumns_ColWidthOutOfRangeIsSanitizedNotRejected(t *testing.T) {
cases := []struct {
name string
width int
want int
}{
{"zero (unconfigured) is left alone", 0, 0},
{"in range is left alone", 150, 150},
{"lower bound is left alone", colWidthMin, colWidthMin},
{"upper bound is left alone", colWidthMax, colWidthMax},
{"too small falls back to the sentinel", colWidthMin - 1, 0},
{"too large falls back to the sentinel", colWidthMax + 1, 0},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
cols := []tools.SysColumns{{JsonField: "name", ColWidth: tc.width}}
if err := validateAndSanitizeColumns(cols); err != nil {
t.Fatalf("colWidth %d: want no error (out-of-range sanitizes, it does not reject), got %v", tc.width, err)
}
if cols[0].ColWidth != tc.want {
t.Errorf("colWidth %d: want sanitized to %d, got %d", tc.width, tc.want, cols[0].ColWidth)
}
})
}
}
func TestValidateAndSanitizeColumns_DefaultValueExpressionRejected(t *testing.T) {
cases := []struct {
name string
defaultValue string
wantErr bool
}{
{"plain literal", "0", false},
{"plain string literal", "active", false},
{"empty (unconfigured)", "", false},
{"function call rejected", "Date.now()", true},
{"template literal rejected", "`x`", true},
{"arrow function rejected", "() => 1", true},
{"statement separator rejected", "1; drop", true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := validateAndSanitizeColumns([]tools.SysColumns{{JsonField: "name", DefaultValue: tc.defaultValue}})
if tc.wantErr && err == nil {
t.Errorf("defaultValue %q: want error, got nil", tc.defaultValue)
}
if !tc.wantErr && err != nil {
t.Errorf("defaultValue %q: want no error, got %v", tc.defaultValue, err)
}
})
}
}
func newBusinessNameTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(new(tools.SysTables)); err != nil {
t.Fatalf("migrate sys_tables: %v", err)
}
return db
}
func TestValidateBusinessNameUnique(t *testing.T) {
db := newBusinessNameTestDB(t)
existing := tools.SysTables{TBName: "sys_widget", PackageName: "biz", BusinessName: "widget"}
if err := db.Table("sys_tables").Create(&existing).Error; err != nil {
t.Fatalf("seed: %v", err)
}
t.Run("same package, same businessName, different table: rejected", func(t *testing.T) {
other := tools.SysTables{TBName: "sys_widget_copy", PackageName: "biz", BusinessName: "widget"}
if err := db.Table("sys_tables").Create(&other).Error; err != nil {
t.Fatalf("seed second row: %v", err)
}
// Unscoped: a plain Delete only soft-deletes (SysTables carries
// common.ModelTime), which would leave this row's businessName
// looking taken for the next subtest - production's own delete path
// (SysTables.BatchDelete) hard-deletes for the same reason.
defer db.Table("sys_tables").Unscoped().Delete(&other)
if err := validateBusinessNameUnique(db, "biz", "widget", other.TableId); err == nil {
t.Error("want error for a businessName already used by another table in the same package, got nil")
}
})
t.Run("different package, same businessName: allowed", func(t *testing.T) {
if err := validateBusinessNameUnique(db, "other-pkg", "widget", 0); err != nil {
t.Errorf("want no error across different packages, got %v", err)
}
})
t.Run("a table checking against its own current name: allowed", func(t *testing.T) {
if err := validateBusinessNameUnique(db, "biz", "widget", existing.TableId); err != nil {
t.Errorf("want no error when the only match is the row being saved itself, got %v", err)
}
})
}