mirror of
https://github.com/go-admin-team/go-admin.git
synced 2026-09-28 20:25:53 +00:00
Merge pull request #955 from go-admin-team/fix/gen-path-and-authz
fix🐛: confine the code generator's writes and check roles on its routes
This commit is contained in:
+44
-21
@@ -241,6 +241,12 @@ func (e Gen) NOActionsGen(c *gin.Context, tab tools.SysTables) bool {
|
||||
e.Error(500, err, err.Error())
|
||||
return false
|
||||
}
|
||||
// Checked again here, not only on save: a configuration saved before the
|
||||
// save-time check existed is still in sys_tables.
|
||||
if err := validateGenPathFields(tab); err != nil {
|
||||
e.Error(500, err, err.Error())
|
||||
return false
|
||||
}
|
||||
// R2: see the matching call and comment in Preview above.
|
||||
applyInferredColumnWidths(tab.Columns)
|
||||
|
||||
@@ -319,22 +325,23 @@ func (e Gen) NOActionsGen(c *gin.Context, tab tools.SysTables) bool {
|
||||
// up - a flat gen/{BusinessName}.ts would let two tables in different
|
||||
// packages silently overwrite each other's translations, since
|
||||
// BusinessName only has a pattern check, no uniqueness check.
|
||||
back, front := ".", config.GenConfig.FrontPath
|
||||
files := []struct {
|
||||
path string
|
||||
content []byte
|
||||
root, name string
|
||||
content []byte
|
||||
}{
|
||||
{"./app/" + tab.PackageName + "/models/" + tab.TBName + ".go", out[0]},
|
||||
{"./app/" + tab.PackageName + "/apis/" + tab.TBName + ".go", out[1]},
|
||||
{"./app/" + tab.PackageName + "/router/" + tab.TBName + ".go", out[2]},
|
||||
{config.GenConfig.FrontPath + "/api/" + tab.PackageName + "/" + tab.MLTBName + ".ts", out[3]},
|
||||
{config.GenConfig.FrontPath + "/views/" + tab.PackageName + "/" + tab.MLTBName + "/index.vue", out[4]},
|
||||
{"./app/" + tab.PackageName + "/service/dto/" + tab.TBName + ".go", out[5]},
|
||||
{"./app/" + tab.PackageName + "/service/" + tab.TBName + ".go", out[6]},
|
||||
{config.GenConfig.FrontPath + "/lang/zh-CN/gen/" + tab.PackageName + "/" + tab.BusinessName + ".ts", out[7]},
|
||||
{config.GenConfig.FrontPath + "/lang/en-US/gen/" + tab.PackageName + "/" + tab.BusinessName + ".ts", out[8]},
|
||||
{back, "app/" + tab.PackageName + "/models/" + tab.TBName + ".go", out[0]},
|
||||
{back, "app/" + tab.PackageName + "/apis/" + tab.TBName + ".go", out[1]},
|
||||
{back, "app/" + tab.PackageName + "/router/" + tab.TBName + ".go", out[2]},
|
||||
{front, "api/" + tab.PackageName + "/" + tab.MLTBName + ".ts", out[3]},
|
||||
{front, "views/" + tab.PackageName + "/" + tab.MLTBName + "/index.vue", out[4]},
|
||||
{back, "app/" + tab.PackageName + "/service/dto/" + tab.TBName + ".go", out[5]},
|
||||
{back, "app/" + tab.PackageName + "/service/" + tab.TBName + ".go", out[6]},
|
||||
{front, "lang/zh-CN/gen/" + tab.PackageName + "/" + tab.BusinessName + ".ts", out[7]},
|
||||
{front, "lang/en-US/gen/" + tab.PackageName + "/" + tab.BusinessName + ".ts", out[8]},
|
||||
}
|
||||
for _, f := range files {
|
||||
if err := writeGenerated(f.path, f.content); err != nil {
|
||||
if err := writeGenerated(f.root, f.name, f.content); err != nil {
|
||||
log.Error(err)
|
||||
e.Error(500, err, fmt.Sprintf("生成文件写入失败!错误详情:%s", err.Error()))
|
||||
return false
|
||||
@@ -373,7 +380,7 @@ func (e Gen) genApiToFile(c *gin.Context, tab tools.SysTables) bool {
|
||||
e.Error(500, err, fmt.Sprintf("数据迁移模版渲染失败!错误详情:%s", err.Error()))
|
||||
return false
|
||||
}
|
||||
if err := writeGenerated("./cmd/migrate/migration/version-local/"+i+"_migrate.go", out[0]); err != nil {
|
||||
if err := writeGenerated(".", "cmd/migrate/migration/version-local/"+i+"_migrate.go", out[0]); err != nil {
|
||||
e.Logger.Error(err)
|
||||
e.Error(500, err, fmt.Sprintf("数据迁移文件写入失败!错误详情:%s", err.Error()))
|
||||
return false
|
||||
@@ -554,14 +561,30 @@ func renderAll(data any, tpls ...*template.Template) ([][]byte, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// writeGenerated writes one generated file, creating its directory first,
|
||||
// which pkg.FileCreate does not do. It replaced pkg.FileCreate here while
|
||||
// that returned no error and, when the file could not be created, ended the
|
||||
// process with log.Fatalln - one unwritable path took the whole server down
|
||||
// with it. go-admin-core v2.11.0 made pkg.FileCreate return the error.
|
||||
func writeGenerated(path string, content []byte) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), os.ModePerm); err != nil {
|
||||
// writeGenerated writes content to name, a slash-separated path under root,
|
||||
// creating root and the directories in between.
|
||||
//
|
||||
// It goes through os.Root, so name cannot leave root: a ".." component, an
|
||||
// absolute path, or a symlink under root that points outside it is refused
|
||||
// rather than followed. root itself is configuration (the working directory,
|
||||
// or gen.frontpath), and may be or contain a symlink.
|
||||
//
|
||||
// It replaced pkg.FileCreate here while that returned no error and, when the
|
||||
// file could not be created, ended the process with log.Fatalln - one
|
||||
// unwritable path took the whole server down with it. go-admin-core v2.11.0
|
||||
// made pkg.FileCreate return the error.
|
||||
func writeGenerated(root, name string, content []byte) error {
|
||||
if err := os.MkdirAll(root, os.ModePerm); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, content, 0o644)
|
||||
r, err := os.OpenRoot(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer r.Close()
|
||||
name = filepath.FromSlash(name)
|
||||
if err := r.MkdirAll(filepath.Dir(name), os.ModePerm); err != nil {
|
||||
return err
|
||||
}
|
||||
return r.WriteFile(name, content, 0o644)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"go-admin/app/other/models/tools"
|
||||
)
|
||||
|
||||
// The generator builds the paths it writes to from three fields of a table's
|
||||
// configuration. These tests hold that none of them can take a write outside
|
||||
// the working directory or gen.frontpath, and that a refused write leaves
|
||||
// nothing behind - inside the roots or outside them.
|
||||
|
||||
// filesUnder lists every file below dir, or none when dir does not exist.
|
||||
func filesUnder(t *testing.T, dir string) []string {
|
||||
t.Helper()
|
||||
var files []string
|
||||
_ = filepath.WalkDir(dir, func(p string, d fs.DirEntry, err error) error {
|
||||
if err == nil && !d.IsDir() {
|
||||
files = append(files, p)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return files
|
||||
}
|
||||
|
||||
func TestNOActionsGenWritesEveryFileInsideItsRoots(t *testing.T) {
|
||||
dir := genWorkspace(t)
|
||||
|
||||
var ok bool
|
||||
bodies := runGen(t, nil, func(c *gin.Context) { ok = Gen{}.NOActionsGen(c, genTable()) }, nil)
|
||||
if !ok || len(bodies) != 0 {
|
||||
t.Fatalf("ok=%v, responses %+v; want true and no error response", ok, bodies)
|
||||
}
|
||||
want := []string{
|
||||
"app/admin/models/gen_err.go",
|
||||
"app/admin/apis/gen_err.go",
|
||||
"app/admin/router/gen_err.go",
|
||||
"app/admin/service/dto/gen_err.go",
|
||||
"app/admin/service/gen_err.go",
|
||||
"ui/api/admin/gen-err.ts",
|
||||
"ui/views/admin/gen-err/index.vue",
|
||||
"ui/lang/zh-CN/gen/admin/genErr.ts",
|
||||
"ui/lang/en-US/gen/admin/genErr.ts",
|
||||
}
|
||||
for _, rel := range want {
|
||||
if _, err := os.Stat(filepath.Join(dir, filepath.FromSlash(rel))); err != nil {
|
||||
t.Errorf("%s was not written: %v", rel, err)
|
||||
}
|
||||
}
|
||||
if got := len(writtenFiles(t, dir)); got != len(want) {
|
||||
t.Errorf("wrote %d files, want %d: %v", got, len(want), writtenFiles(t, dir))
|
||||
}
|
||||
}
|
||||
|
||||
// A configuration already in sys_tables reaches NOActionsGen as it was saved,
|
||||
// so this is also the case of a row saved before the save-time check existed.
|
||||
func TestNOActionsGenRefusesAPathFieldThatLeavesItsRoot(t *testing.T) {
|
||||
cases := map[string]func(*tools.SysTables){
|
||||
"packageName": func(tab *tools.SysTables) { tab.PackageName = "../../escaped" },
|
||||
"tableName": func(tab *tools.SysTables) { tab.TBName = "../../../escaped" },
|
||||
"businessName": func(tab *tools.SysTables) { tab.BusinessName = "../../../../escaped" },
|
||||
"absolute": func(tab *tools.SysTables) { tab.PackageName = "/escaped" },
|
||||
}
|
||||
for name, spoil := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
dir := genWorkspace(t)
|
||||
tab := genTable()
|
||||
spoil(&tab)
|
||||
|
||||
var ok bool
|
||||
bodies := runGen(t, nil, func(c *gin.Context) { ok = Gen{}.NOActionsGen(c, tab) }, nil)
|
||||
|
||||
if ok || len(bodies) != 1 || bodies[0].Code != 500 || !strings.Contains(bodies[0].Msg, "不合法") {
|
||||
t.Fatalf("ok=%v, responses %+v; want false and one 500 naming the invalid field", ok, bodies)
|
||||
}
|
||||
if files := writtenFiles(t, dir); len(files) > 0 {
|
||||
t.Errorf("wrote %v inside the workspace", files)
|
||||
}
|
||||
// Where each of the paths above would have landed.
|
||||
for _, p := range []string{filepath.Join(dir, "..", "escaped"), filepath.Join(filepath.Dir(dir), "..", "escaped"), "/escaped"} {
|
||||
if _, err := os.Stat(p); err == nil {
|
||||
t.Errorf("%s exists after a refused generation", p)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A symlink under a root that points out of it is the case a name check
|
||||
// cannot see: every component of the name is legal. os.Root refuses to follow
|
||||
// it. The file planted where the write would land must survive unchanged.
|
||||
func TestNOActionsGenDoesNotFollowASymlinkOutOfItsRoot(t *testing.T) {
|
||||
for _, link := range []string{"app/admin", "ui/api"} {
|
||||
t.Run(link, func(t *testing.T) {
|
||||
dir := genWorkspace(t)
|
||||
outside := t.TempDir()
|
||||
planted := filepath.Join(outside, "models", "gen_err.go")
|
||||
if err := os.MkdirAll(filepath.Dir(planted), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(planted, []byte("original"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
at := filepath.Join(dir, filepath.FromSlash(link))
|
||||
if err := os.MkdirAll(filepath.Dir(at), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(outside, at); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var ok bool
|
||||
bodies := runGen(t, nil, func(c *gin.Context) { ok = Gen{}.NOActionsGen(c, genTable()) }, nil)
|
||||
|
||||
if ok || len(bodies) != 1 || bodies[0].Code != 500 || !strings.Contains(bodies[0].Msg, "生成文件写入失败") {
|
||||
t.Fatalf("ok=%v, responses %+v; want false and one 500 for the write", ok, bodies)
|
||||
}
|
||||
if b, err := os.ReadFile(planted); err != nil || string(b) != "original" {
|
||||
t.Errorf("the file outside the root reads %q (err %v), want it untouched", b, err)
|
||||
}
|
||||
if files := filesUnder(t, outside); len(files) != 1 {
|
||||
t.Errorf("files outside the root: %v, want only the planted one", files)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// putTable saves a table's configuration the way the config page does.
|
||||
func putTable(t *testing.T, tab tools.SysTables) genBody {
|
||||
t.Helper()
|
||||
body, _ := json.Marshal(tab)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest(http.MethodPut, "/", strings.NewReader(string(body)))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
c.Set("db", genDB(t))
|
||||
SysTable{}.Update(c)
|
||||
var res genBody
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &res); err != nil {
|
||||
t.Fatalf("decoding %q: %v", w.Body.String(), err)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func TestSavingAConfigurationRefusesAPathField(t *testing.T) {
|
||||
db := genDB(t)
|
||||
stored := genTable()
|
||||
created, err := stored.Create(db)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
edited := created
|
||||
edited.PackageName = "../escaped"
|
||||
if res := putTable(t, edited); res.Code != 500 || !strings.Contains(res.Msg, "packageName") {
|
||||
t.Fatalf("response %+v; want 500 naming packageName", res)
|
||||
}
|
||||
|
||||
var row tools.SysTables
|
||||
if err := db.First(&row, created.TableId).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if row.PackageName != "admin" {
|
||||
t.Errorf("package_name = %q after a refused save, want it unchanged", row.PackageName)
|
||||
}
|
||||
}
|
||||
|
||||
// What the config page and the importer produce has to keep passing.
|
||||
func TestValidateGenPathFieldsAcceptsWhatTheConfigPageAccepts(t *testing.T) {
|
||||
for _, tab := range []tools.SysTables{
|
||||
{PackageName: "admin", TBName: "sys_user", BusinessName: "sysUser"},
|
||||
{PackageName: "shop2", TBName: "order2", BusinessName: "order2"},
|
||||
{PackageName: "x", TBName: "T_Mixed_1", BusinessName: "tMixed1"},
|
||||
} {
|
||||
if err := validateGenPathFields(tab); err != nil {
|
||||
t.Errorf("%+v refused: %v", tab, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The importer reads table names from the database, which accepts names no
|
||||
// file can carry. A list holding one is refused whole: the valid table
|
||||
// beside it is not imported either.
|
||||
func TestImportRefusesATableNameThatCannotBeAFileName(t *testing.T) {
|
||||
g := newGenEnv(t)
|
||||
const good, bad = "gpa_ok", "gpa-dash"
|
||||
// dropFixture does not quote the name, and the point of bad is that it
|
||||
// needs quoting, so the rows and tables are removed here.
|
||||
drop := func() {
|
||||
g.db.Unscoped().Where("table_name IN ?", []string{good, bad}).Delete(&tools.SysTables{})
|
||||
for _, table := range []string{good, bad} {
|
||||
g.db.Exec("DROP TABLE IF EXISTS `" + table + "`")
|
||||
}
|
||||
}
|
||||
drop()
|
||||
t.Cleanup(drop)
|
||||
for _, table := range []string{good, bad} {
|
||||
if err := g.db.Exec("CREATE TABLE `" + table + "` (id int NOT NULL AUTO_INCREMENT PRIMARY KEY," + auditColumns + ")").Error; err != nil {
|
||||
t.Fatalf("creating %s: %v", table, err)
|
||||
}
|
||||
}
|
||||
|
||||
if code := callHandler(t, g.db, SysTable{}.Insert, "/?tables="+good+","+bad, nil); code == http.StatusOK {
|
||||
t.Fatal("the import succeeded")
|
||||
}
|
||||
var n int64
|
||||
g.db.Model(&tools.SysTables{}).Where("table_name IN ?", []string{good, bad}).Count(&n)
|
||||
if n != 0 {
|
||||
t.Errorf("%d table(s) imported from a refused list, want 0", n)
|
||||
}
|
||||
}
|
||||
@@ -172,17 +172,26 @@ func (e SysTable) Insert(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Every table is read and checked before any is saved, so a list with
|
||||
// one table whose name cannot become a file name is refused whole
|
||||
// instead of importing the tables ahead of it.
|
||||
tables := make([]tools.SysTables, 0, len(tablesList))
|
||||
for i := 0; i < len(tablesList); i++ {
|
||||
|
||||
data, err := genTableInit(db, tablesList, i, c)
|
||||
if err != nil {
|
||||
log.Errorf("genTableInit error, %s", err.Error())
|
||||
e.Error(500, err, "")
|
||||
return
|
||||
}
|
||||
|
||||
_, err = data.Create(db)
|
||||
if err != nil {
|
||||
if err = validateGenPathFields(data); err != nil {
|
||||
log.Errorf("validate table error, %s", err.Error())
|
||||
e.Error(500, err, err.Error())
|
||||
return
|
||||
}
|
||||
tables = append(tables, data)
|
||||
}
|
||||
for i := range tables {
|
||||
if _, err = tables[i].Create(db); err != nil {
|
||||
log.Errorf("Create error, %s", err.Error())
|
||||
e.Error(500, err, "")
|
||||
return
|
||||
@@ -377,6 +386,11 @@ func (e SysTable) Update(c *gin.Context) {
|
||||
e.Error(500, err, err.Error())
|
||||
return
|
||||
}
|
||||
if err = validateGenPathFields(data); err != nil {
|
||||
log.Errorf("validate table error, %s", err.Error())
|
||||
e.Error(500, err, err.Error())
|
||||
return
|
||||
}
|
||||
if err = validateBusinessNameUnique(db, data.PackageName, data.BusinessName, data.TableId); err != nil {
|
||||
log.Errorf("validate businessName error, %s", err.Error())
|
||||
e.Error(500, err, err.Error())
|
||||
|
||||
@@ -110,3 +110,44 @@ func validateBusinessNameUnique(db *gorm.DB, packageName, businessName string, t
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// The three fields gen.go joins into the paths it writes to. Each is checked
|
||||
// against what it has to be where it lands, not against one shared pattern:
|
||||
//
|
||||
// - packageName names a Go package and the app/{packageName} directory.
|
||||
// Lowercase letters and digits, as a Go package name should be; no
|
||||
// hyphen, which Go rejects, and no underscore, which genInfoForm.vue
|
||||
// already refuses.
|
||||
// - tableName is the imported table's own name, and becomes a .go file name
|
||||
// and, with "_" turned into "-", a .ts/.vue one. Letters, digits and
|
||||
// underscores: what a table the importer can read is normally called, and
|
||||
// nothing that can step out of a directory.
|
||||
// - businessName is a JavaScript identifier and a .ts file name. It starts
|
||||
// with a lowercase letter, as genInfoForm.vue requires, but may carry
|
||||
// digits, which a name derived from a table such as order2 does.
|
||||
//
|
||||
// None of the three allows a dot or a path separator, so none can name a
|
||||
// parent directory or an absolute path. The rules are no stricter than the
|
||||
// config page's own, so nothing it accepts is refused here.
|
||||
var (
|
||||
packageNamePattern = regexp.MustCompile(`^[a-z][a-z0-9]*$`)
|
||||
tableNamePattern = regexp.MustCompile(`^[A-Za-z0-9_]+$`)
|
||||
businessNamePattern = regexp.MustCompile(`^[a-z][A-Za-z0-9]*$`)
|
||||
)
|
||||
|
||||
// validateGenPathFields checks the fields gen.go builds file paths from. It
|
||||
// runs where a configuration is saved and again before files are written, so
|
||||
// a row saved before this check existed is refused at generation rather than
|
||||
// trusted because it is already in the database.
|
||||
func validateGenPathFields(tab tools.SysTables) error {
|
||||
if !packageNamePattern.MatchString(tab.PackageName) {
|
||||
return fmt.Errorf("packageName=%q 不合法:只能包含小写字母和数字,且以字母开头", tab.PackageName)
|
||||
}
|
||||
if !tableNamePattern.MatchString(tab.TBName) {
|
||||
return fmt.Errorf("tableName=%q 不合法:只能包含字母、数字和下划线", tab.TBName)
|
||||
}
|
||||
if !businessNamePattern.MatchString(tab.BusinessName) {
|
||||
return fmt.Errorf("businessName=%q 不合法:只能包含字母和数字,且以小写字母开头", tab.BusinessName)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
|
||||
"go-admin/app/admin/apis"
|
||||
"go-admin/app/other/apis/tools"
|
||||
"go-admin/common/middleware"
|
||||
)
|
||||
|
||||
// GenWriteRoutesEnabled reports whether the code generator's writing endpoints
|
||||
@@ -52,7 +53,12 @@ func sysNoCheckRoleRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddlew
|
||||
r1.GET("/captcha", sys.GenerateCaptchaHandler)
|
||||
}
|
||||
|
||||
r := v1.Group("").Use(authMiddleware.MiddlewareFunc())
|
||||
// AuthCheckRole on every generator route: a role reaches them through the
|
||||
// code generator's menus, which the 1786700011000 migration binds them to.
|
||||
// They used to be in CasbinExclude, which let any account that could log
|
||||
// in read and change any table's configuration and, in dev mode, write
|
||||
// files from it.
|
||||
r := v1.Group("").Use(authMiddleware.MiddlewareFunc()).Use(middleware.AuthCheckRole())
|
||||
{
|
||||
gen := tools.Gen{}
|
||||
r.GET("/gen/preview/:tableId", gen.Preview)
|
||||
@@ -67,7 +73,7 @@ func sysNoCheckRoleRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddlew
|
||||
}
|
||||
|
||||
func registerDBRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddleware) {
|
||||
db := v1.Group("/db").Use(authMiddleware.MiddlewareFunc())
|
||||
db := v1.Group("/db").Use(authMiddleware.MiddlewareFunc()).Use(middleware.AuthCheckRole())
|
||||
{
|
||||
gen := tools.Gen{}
|
||||
db.GET("/tables/page", gen.GetDBTableList)
|
||||
@@ -79,8 +85,8 @@ func registerSysTableRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddl
|
||||
tables := v1.Group("/sys/tables")
|
||||
{
|
||||
sysTable := tools.SysTable{}
|
||||
tables.Group("").Use(authMiddleware.MiddlewareFunc()).GET("/page", sysTable.GetPage)
|
||||
tablesInfo := tables.Group("/info").Use(authMiddleware.MiddlewareFunc())
|
||||
tables.Group("").Use(authMiddleware.MiddlewareFunc()).Use(middleware.AuthCheckRole()).GET("/page", sysTable.GetPage)
|
||||
tablesInfo := tables.Group("/info").Use(authMiddleware.MiddlewareFunc()).Use(middleware.AuthCheckRole())
|
||||
{
|
||||
tablesInfo.POST("", sysTable.Insert)
|
||||
tablesInfo.PUT("", sysTable.Update)
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/glebarez/sqlite"
|
||||
mycasbin "github.com/go-admin-team/go-admin-core/v2/casbin"
|
||||
jwt "github.com/go-admin-team/go-admin-core/v2/jwtauth"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/config"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Every route the code generator registers, as a request and as the policy
|
||||
// that grants it.
|
||||
var genRoutes = []struct{ method, path, policy string }{
|
||||
{"GET", "/api/v1/gen/preview/1", "/api/v1/gen/preview/:tableId"},
|
||||
{"GET", "/api/v1/gen/toproject/1", "/api/v1/gen/toproject/:tableId"},
|
||||
{"GET", "/api/v1/gen/apitofile/1", "/api/v1/gen/apitofile/:tableId"},
|
||||
{"GET", "/api/v1/gen/todb/1", "/api/v1/gen/todb/:tableId"},
|
||||
{"GET", "/api/v1/gen/tabletree", "/api/v1/gen/tabletree"},
|
||||
{"GET", "/api/v1/db/tables/page", "/api/v1/db/tables/page"},
|
||||
{"GET", "/api/v1/db/columns/page", "/api/v1/db/columns/page"},
|
||||
{"GET", "/api/v1/sys/tables/page", "/api/v1/sys/tables/page"},
|
||||
{"GET", "/api/v1/sys/tables/info", "/api/v1/sys/tables/info"},
|
||||
{"GET", "/api/v1/sys/tables/info/1", "/api/v1/sys/tables/info/:tableId"},
|
||||
{"POST", "/api/v1/sys/tables/info", "/api/v1/sys/tables/info"},
|
||||
{"PUT", "/api/v1/sys/tables/info", "/api/v1/sys/tables/info"},
|
||||
{"DELETE", "/api/v1/sys/tables/info/1", "/api/v1/sys/tables/info/:tableId"},
|
||||
}
|
||||
|
||||
// genAuthzEngine serves the generator's routes behind a real JWT middleware
|
||||
// and a real casbin enforcer for host, holding the policies given.
|
||||
func genAuthzEngine(t *testing.T, host string, grant func(add func(role, path, method string))) (*gin.Engine, *jwt.GinJWTMiddleware) {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
previousMode := config.ApplicationConfig.Mode
|
||||
config.ApplicationConfig.Mode = "dev" // the writing routes exist only here
|
||||
t.Cleanup(func() { config.ApplicationConfig.Mode = previousMode })
|
||||
|
||||
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
previousInterval := mycasbin.ReloadInterval
|
||||
mycasbin.ReloadInterval = 0
|
||||
t.Cleanup(func() { mycasbin.ReloadInterval = previousInterval })
|
||||
e := mycasbin.Setup(db, host)
|
||||
grant(func(role, path, method string) {
|
||||
if _, err := e.AddPolicy(role, path, method); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
previous := sdk.Runtime.GetCasbinByTenant(host)
|
||||
sdk.Runtime.SetCasbinByTenant(host, e)
|
||||
t.Cleanup(func() { sdk.Runtime.SetCasbinByTenant(host, previous) })
|
||||
|
||||
mw, err := jwt.New(&jwt.GinJWTMiddleware{
|
||||
Realm: "test",
|
||||
Key: []byte("test-key"),
|
||||
Timeout: time.Hour,
|
||||
PayloadFunc: func(data interface{}) jwt.MapClaims { return data.(jwt.MapClaims) },
|
||||
TokenLookup: "header: Authorization",
|
||||
TokenHeadName: "Bearer",
|
||||
TimeFunc: time.Now,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := gin.New()
|
||||
// A request AuthCheckRole lets through reaches a handler with no logger or
|
||||
// database in its context, which panics. Recovery turns that into a 500:
|
||||
// past the check, which is all these tests ask.
|
||||
r.Use(gin.RecoveryWithWriter(io.Discard))
|
||||
v1 := r.Group("/api/v1")
|
||||
sysNoCheckRoleRouter(v1, mw)
|
||||
registerDBRouter(v1, mw)
|
||||
registerSysTableRouter(v1, mw)
|
||||
return r, mw
|
||||
}
|
||||
|
||||
// deniedByRole reports whether AuthCheckRole refused the request. It answers
|
||||
// HTTP 200 with code 403 in the body; anything else got past it, even if the
|
||||
// handler behind it then failed for want of a database.
|
||||
func deniedByRole(t *testing.T, r *gin.Engine, mw *jwt.GinJWTMiddleware, host, role, method, path string) bool {
|
||||
t.Helper()
|
||||
token, _, err := mw.TokenGenerator(jwt.MapClaims{"identity": 7, "rolekey": role, "roleid": 2})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(method, path, strings.NewReader("{}"))
|
||||
req.Host = host
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
r.ServeHTTP(w, req)
|
||||
var body struct{ Code int }
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &body)
|
||||
return body.Code == 403 && strings.Contains(w.Body.String(), "没有该接口访问权限")
|
||||
}
|
||||
|
||||
func TestGeneratorRoutesRequireARoleThatHoldsThem(t *testing.T) {
|
||||
const host = "gen-authz.test"
|
||||
r, mw := genAuthzEngine(t, host, func(add func(role, path, method string)) {
|
||||
for _, route := range genRoutes {
|
||||
add("developer", route.policy, route.method)
|
||||
}
|
||||
})
|
||||
|
||||
for _, route := range genRoutes {
|
||||
name := route.method + " " + route.path
|
||||
if !deniedByRole(t, r, mw, host, "clerk", route.method, route.path) {
|
||||
t.Errorf("%s: a role holding no policy got past AuthCheckRole", name)
|
||||
}
|
||||
if deniedByRole(t, r, mw, host, "developer", route.method, route.path) {
|
||||
t.Errorf("%s: a role holding the policy was refused", name)
|
||||
}
|
||||
if deniedByRole(t, r, mw, host, "admin", route.method, route.path) {
|
||||
t.Errorf("%s: admin was refused", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The captcha shares a registration function with the generator's routes and
|
||||
// has to stay reachable before anyone has logged in.
|
||||
func TestCaptchaStaysPublic(t *testing.T) {
|
||||
r, _ := genAuthzEngine(t, "gen-authz-captcha.test", func(func(string, string, string)) {})
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/v1/captcha", nil))
|
||||
if w.Code == http.StatusUnauthorized || strings.Contains(w.Body.String(), "没有该接口访问权限") {
|
||||
t.Errorf("captcha answered %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package version
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"runtime"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"go-admin/app/admin/models"
|
||||
"go-admin/cmd/migrate/migration"
|
||||
common "go-admin/common/models"
|
||||
)
|
||||
|
||||
// Bind the code generator's APIs to its two menus, and grant them to every
|
||||
// role that already holds one of those menus.
|
||||
//
|
||||
// The generator's routes were in CasbinExclude, or mounted without
|
||||
// AuthCheckRole, so any account that could log in reached them. They now go
|
||||
// through AuthCheckRole. A role is granted an API through the menus it is
|
||||
// given: SysRole's update writes a casbin_rule for every API bound to each of
|
||||
// its menus in sys_menu_api_rule. The seed data bound none to the
|
||||
// generator's menus, so without this a role given 代码生成 would be refused
|
||||
// by every endpoint the page calls.
|
||||
//
|
||||
// Binding alone only reaches roles saved after this migration. Roles that
|
||||
// already hold a generator menu are granted here, so a deployment that gave
|
||||
// the generator to a role keeps it working across the upgrade. The admin
|
||||
// role is let through by AuthCheckRole without a policy and needs none.
|
||||
//
|
||||
// Menus are found by component, not id: a deployment may have renumbered
|
||||
// them, and one that deleted a menu has nobody to grant and nothing to bind.
|
||||
// An API missing from sys_api is created, since its row is what the binding
|
||||
// points at.
|
||||
//
|
||||
// Ordered after 1786700003000 (the soft-delete conversion), so the runtime
|
||||
// models under app/admin/models are used, not cmd/migrate/migration/models -
|
||||
// see schema_coverage_test.go's TestPostConversionMigrationsAvoidFrozenSeedModels.
|
||||
func init() {
|
||||
_, fileName, _, _ := runtime.Caller(0)
|
||||
migration.Migrate.SetVersion(migration.GetFilename(fileName), _1786700011000GenMenuApis)
|
||||
}
|
||||
|
||||
// genMenuApis is which page calls which endpoint, read off go-admin-ui's
|
||||
// src/api/tools/gen.ts and the views under dev-tools/gen.
|
||||
var genMenuApis = []struct {
|
||||
component string
|
||||
apis []models.SysApi
|
||||
}{
|
||||
{"/dev-tools/gen/index", []models.SysApi{
|
||||
{Title: "代码生成表列表", Path: "/api/v1/sys/tables/page", Action: "GET"},
|
||||
{Title: "数据库表列表", Path: "/api/v1/db/tables/page", Action: "GET"},
|
||||
{Title: "数据表列列表", Path: "/api/v1/db/columns/page", Action: "GET"},
|
||||
{Title: "导入表", Path: "/api/v1/sys/tables/info", Action: "POST"},
|
||||
{Title: "删除表配置", Path: "/api/v1/sys/tables/info/:tableId", Action: "DELETE"},
|
||||
{Title: "生成预览通过id获取", Path: "/api/v1/gen/preview/:tableId", Action: "GET"},
|
||||
{Title: "数据库表生成到项目", Path: "/api/v1/gen/toproject/:tableId", Action: "GET"},
|
||||
{Title: "生成api带文件", Path: "/api/v1/gen/apitofile/:tableId", Action: "GET"},
|
||||
{Title: "数据库表生成到DB", Path: "/api/v1/gen/todb/:tableId", Action: "GET"},
|
||||
}},
|
||||
{"/dev-tools/gen/editTable", []models.SysApi{
|
||||
{Title: "表配置详情", Path: "/api/v1/sys/tables/info/:tableId", Action: "GET"},
|
||||
{Title: "按表名查询表配置", Path: "/api/v1/sys/tables/info", Action: "GET"},
|
||||
{Title: "修改表配置", Path: "/api/v1/sys/tables/info", Action: "PUT"},
|
||||
{Title: "关系表数据【代码生成】", Path: "/api/v1/gen/tabletree", Action: "GET"},
|
||||
}},
|
||||
}
|
||||
|
||||
func _1786700011000GenMenuApis(db *gorm.DB, version string) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := bindGenMenuApis(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Create(&common.Migration{Version: version}).Error
|
||||
})
|
||||
}
|
||||
|
||||
// bindGenMenuApis is split out so tests can run it without sys_migration.
|
||||
func bindGenMenuApis(tx *gorm.DB) error {
|
||||
for _, m := range genMenuApis {
|
||||
var menu models.SysMenu
|
||||
err := tx.Where("component = ?", m.component).First(&menu).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
apis := make([]models.SysApi, 0, len(m.apis))
|
||||
for _, want := range m.apis {
|
||||
var api models.SysApi
|
||||
err := tx.Where("path = ? AND action = ?", want.Path, want.Action).First(&api).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
api = want
|
||||
api.Type = "SYS"
|
||||
err = tx.Create(&api).Error
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
apis = append(apis, api)
|
||||
}
|
||||
if err := tx.Model(&menu).Association("SysApi").Append(apis); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var roleKeys []string
|
||||
if err := tx.Model(&models.SysRole{}).
|
||||
Joins("JOIN sys_role_menu ON sys_role_menu.role_id = sys_role.role_id").
|
||||
Where("sys_role_menu.menu_id = ?", menu.MenuId).
|
||||
Distinct().Pluck("sys_role.role_key", &roleKeys).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for _, key := range roleKeys {
|
||||
for _, a := range apis {
|
||||
if err := tx.Exec(
|
||||
"INSERT INTO casbin_rule (ptype, v0, v1, v2, v3, v4, v5) SELECT 'p', ?, ?, ?, '', '', '' WHERE NOT EXISTS (SELECT 1 FROM casbin_rule WHERE ptype='p' AND v0=? AND v1=? AND v2=?)",
|
||||
key, a.Path, a.Action, key, a.Path, a.Action,
|
||||
).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package version
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
|
||||
adminmodels "go-admin/app/admin/models"
|
||||
)
|
||||
|
||||
// bindGenMenuApis writes casbin_rule with INSERT ... SELECT ... WHERE NOT
|
||||
// EXISTS and sys_menu_api_rule through gorm's association code; both are
|
||||
// dialect-sensitive, and the other tests for it run on SQLite only. This runs
|
||||
// the same grant on every database CI has.
|
||||
|
||||
const mysqlDSNEnv = "GO_ADMIN_TEST_MYSQL_DSN"
|
||||
|
||||
func mysqlDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
dsn := os.Getenv(mysqlDSNEnv)
|
||||
if dsn == "" {
|
||||
if os.Getenv("CI") != "" {
|
||||
t.Fatalf("%s is not set while CI is: the MySQL migration tests must not skip here", mysqlDSNEnv)
|
||||
}
|
||||
t.Skipf("%s is not set; skipping the MySQL migration tests", mysqlDSNEnv)
|
||||
}
|
||||
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("connecting to %s: %v", mysqlDSNEnv, err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
// casbinRuleRow is the shape gorm-adapter gives casbin_rule, declared here so
|
||||
// each dialect builds the table its own way.
|
||||
type casbinRuleRow struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement"`
|
||||
Ptype string `gorm:"size:100"`
|
||||
V0 string `gorm:"size:100"`
|
||||
V1 string `gorm:"size:100"`
|
||||
V2 string `gorm:"size:100"`
|
||||
V3 string `gorm:"size:100"`
|
||||
V4 string `gorm:"size:100"`
|
||||
V5 string `gorm:"size:100"`
|
||||
}
|
||||
|
||||
func (casbinRuleRow) TableName() string { return "casbin_rule" }
|
||||
|
||||
func grantOn(t *testing.T, db *gorm.DB) {
|
||||
t.Helper()
|
||||
tables := []any{&casbinRuleRow{}, &adminmodels.SysApi{}, &adminmodels.SysMenu{}, &adminmodels.SysRole{}}
|
||||
drop := func() {
|
||||
for _, join := range []string{"sys_menu_api_rule", "sys_role_menu", "sys_role_dept"} {
|
||||
if db.Migrator().HasTable(join) {
|
||||
if err := db.Migrator().DropTable(join); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, m := range tables {
|
||||
if db.Migrator().HasTable(m) {
|
||||
if err := db.Migrator().DropTable(m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
drop()
|
||||
t.Cleanup(drop)
|
||||
if err := db.AutoMigrate(tables...); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, m := range []adminmodels.SysMenu{
|
||||
{MenuName: "Gen", Component: "/dev-tools/gen/index", MenuType: "C"},
|
||||
{MenuName: "EditTable", Component: "/dev-tools/gen/editTable", MenuType: "C"},
|
||||
} {
|
||||
if err := db.Create(&m).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
var gen adminmodels.SysMenu
|
||||
if err := db.Where("component = ?", "/dev-tools/gen/index").First(&gen).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
role := adminmodels.SysRole{RoleKey: "developer", RoleName: "developer"}
|
||||
if err := db.Create(&role).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Exec("INSERT INTO sys_role_menu (role_id, menu_id) VALUES (?, ?)", role.RoleId, gen.MenuId).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for i := 0; i < 2; i++ {
|
||||
if err := bindGenMenuApis(db); err != nil {
|
||||
t.Fatalf("run %d: %v", i+1, err)
|
||||
}
|
||||
}
|
||||
|
||||
var n int64
|
||||
db.Model(&casbinRuleRow{}).Where("ptype = 'p' AND v0 = ?", "developer").Count(&n)
|
||||
if n != 9 {
|
||||
t.Errorf("developer holds %d policies after two runs, want 9", n)
|
||||
}
|
||||
if bound := db.Model(&gen).Association("SysApi").Count(); bound != 9 {
|
||||
t.Errorf("代码生成 is bound to %d APIs after two runs, want 9", bound)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenMenuApisOnMySQL(t *testing.T) { grantOn(t, mysqlDB(t)) }
|
||||
func TestGenMenuApisOnPostgres(t *testing.T) { grantOn(t, postgresDB(t)) }
|
||||
func TestGenMenuApisOnSQLServer(t *testing.T) { grantOn(t, sqlserverDB(t)) }
|
||||
@@ -0,0 +1,187 @@
|
||||
package version
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
|
||||
"go-admin/app/admin/models"
|
||||
)
|
||||
|
||||
const (
|
||||
genMenuId = 261
|
||||
editMenuId = 262
|
||||
)
|
||||
|
||||
// openGenMenuDB builds the tables bindGenMenuApis reads and writes, with the
|
||||
// two generator menus under the ids config/db.sql gives them.
|
||||
func openGenMenuDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(new(models.SysApi), new(models.SysMenu), new(models.SysRole)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Exec(`CREATE TABLE casbin_rule (id integer primary key autoincrement,
|
||||
ptype text, v0 text, v1 text, v2 text, v3 text, v4 text, v5 text)`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, m := range []models.SysMenu{
|
||||
{MenuId: genMenuId, MenuName: "Gen", Component: "/dev-tools/gen/index", MenuType: "C"},
|
||||
{MenuId: editMenuId, MenuName: "EditTable", Component: "/dev-tools/gen/editTable", MenuType: "C"},
|
||||
} {
|
||||
if err := db.Create(&m).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func addRole(t *testing.T, db *gorm.DB, id int, key string, menus ...int) {
|
||||
t.Helper()
|
||||
if err := db.Create(&models.SysRole{RoleId: id, RoleKey: key, RoleName: key}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, m := range menus {
|
||||
if err := db.Exec("INSERT INTO sys_role_menu (role_id, menu_id) VALUES (?, ?)", id, m).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func policies(t *testing.T, db *gorm.DB, role string) []string {
|
||||
t.Helper()
|
||||
var rows []struct{ V1, V2 string }
|
||||
if err := db.Raw("SELECT v1, v2 FROM casbin_rule WHERE ptype = 'p' AND v0 = ?", role).Scan(&rows).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := make([]string, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
out = append(out, r.V2+" "+r.V1)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func boundApis(t *testing.T, db *gorm.DB, menuId int) int {
|
||||
t.Helper()
|
||||
menu := models.SysMenu{MenuId: menuId}
|
||||
return int(db.Model(&menu).Association("SysApi").Count())
|
||||
}
|
||||
|
||||
func TestGenMenuApisGrantsWhatARoleAlreadyHolds(t *testing.T) {
|
||||
db := openGenMenuDB(t)
|
||||
addRole(t, db, 2, "developer", genMenuId, editMenuId)
|
||||
addRole(t, db, 3, "editor", editMenuId)
|
||||
addRole(t, db, 4, "clerk")
|
||||
|
||||
if err := bindGenMenuApis(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if n := boundApis(t, db, genMenuId); n != 9 {
|
||||
t.Errorf("代码生成 is bound to %d APIs, want 9", n)
|
||||
}
|
||||
if n := boundApis(t, db, editMenuId); n != 4 {
|
||||
t.Errorf("代码生成修改 is bound to %d APIs, want 4", n)
|
||||
}
|
||||
if got := policies(t, db, "developer"); len(got) != 13 {
|
||||
t.Errorf("developer holds %d policies, want 13: %v", len(got), got)
|
||||
}
|
||||
want := []string{
|
||||
"GET /api/v1/gen/tabletree",
|
||||
"GET /api/v1/sys/tables/info",
|
||||
"GET /api/v1/sys/tables/info/:tableId",
|
||||
"PUT /api/v1/sys/tables/info",
|
||||
}
|
||||
if got := policies(t, db, "editor"); !equal(got, want) {
|
||||
t.Errorf("editor holds %v, want %v", got, want)
|
||||
}
|
||||
if got := policies(t, db, "clerk"); len(got) != 0 {
|
||||
t.Errorf("clerk, who holds neither menu, was granted %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The seed data already registers most of these APIs. Binding must point at
|
||||
// those rows, not add a second row per API next to each.
|
||||
func TestGenMenuApisReusesRegisteredApis(t *testing.T) {
|
||||
db := openGenMenuDB(t)
|
||||
seeded := models.SysApi{Id: 32, Title: "数据库表生成到项目", Path: "/api/v1/gen/toproject/:tableId", Action: "GET", Type: "SYS"}
|
||||
if err := db.Create(&seeded).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := bindGenMenuApis(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var n int64
|
||||
db.Model(&models.SysApi{}).Where("path = ? AND action = ?", seeded.Path, seeded.Action).Count(&n)
|
||||
if n != 1 {
|
||||
t.Errorf("%d rows for %s, want the seeded one only", n, seeded.Path)
|
||||
}
|
||||
var total int64
|
||||
db.Model(&models.SysApi{}).Count(&total)
|
||||
if total != 13 {
|
||||
t.Errorf("sys_api holds %d rows, want 13: the 12 missing created and the seeded one reused", total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenMenuApisRunTwiceAddsNothing(t *testing.T) {
|
||||
db := openGenMenuDB(t)
|
||||
addRole(t, db, 2, "developer", genMenuId, editMenuId)
|
||||
|
||||
for i := 0; i < 2; i++ {
|
||||
if err := bindGenMenuApis(db); err != nil {
|
||||
t.Fatalf("run %d: %v", i+1, err)
|
||||
}
|
||||
}
|
||||
if n := boundApis(t, db, genMenuId); n != 9 {
|
||||
t.Errorf("代码生成 is bound to %d APIs after two runs, want 9", n)
|
||||
}
|
||||
if got := policies(t, db, "developer"); len(got) != 13 {
|
||||
t.Errorf("developer holds %d policies after two runs, want 13", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenMenuApisSkipsADeletedMenuAndADeletedRole(t *testing.T) {
|
||||
db := openGenMenuDB(t)
|
||||
if err := db.Delete(&models.SysMenu{MenuId: editMenuId}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
addRole(t, db, 5, "gone", genMenuId)
|
||||
if err := db.Model(&models.SysRole{}).Where("role_id = ?", 5).
|
||||
Update("deleted_at", time.Now().UnixMilli()).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := bindGenMenuApis(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n := boundApis(t, db, editMenuId); n != 0 {
|
||||
t.Errorf("a deleted menu was bound to %d APIs", n)
|
||||
}
|
||||
if got := policies(t, db, "gone"); len(got) != 0 {
|
||||
t.Errorf("a deleted role was granted %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func equal(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -10,13 +10,6 @@ var CasbinExclude = []UrlInfo{
|
||||
{Url: "/api/v1/dict/type-option-select", Method: "GET"},
|
||||
{Url: "/api/v1/dict-data/option-select", Method: "GET"},
|
||||
{Url: "/api/v1/deptTree", Method: "GET"},
|
||||
{Url: "/api/v1/db/tables/page", Method: "GET"},
|
||||
{Url: "/api/v1/db/columns/page", Method: "GET"},
|
||||
{Url: "/api/v1/gen/toproject/:tableId", Method: "GET"},
|
||||
{Url: "/api/v1/gen/todb/:tableId", Method: "GET"},
|
||||
{Url: "/api/v1/gen/tabletree", Method: "GET"},
|
||||
{Url: "/api/v1/gen/preview/:tableId", Method: "GET"},
|
||||
{Url: "/api/v1/gen/apitofile/:tableId", Method: "GET"},
|
||||
{Url: "/api/v1/getCaptcha", Method: "GET"},
|
||||
{Url: "/api/v1/getinfo", Method: "GET"},
|
||||
{Url: "/api/v1/menuTreeselect", Method: "GET"},
|
||||
|
||||
Reference in New Issue
Block a user