feat: integrate system schema, enhance row-level auth and redesign tests (v1.1.0)
This commit is contained in:
parent
890c899cb7
commit
c22ff9a56d
@ -1,5 +1,12 @@
|
|||||||
# CHANGELOG
|
# CHANGELOG
|
||||||
|
|
||||||
|
## v1.1.0
|
||||||
|
- **内置系统架构 (SystemSchema)**: `_Table`, `_Field`, `_Policy` 等核心元数据表现已内置,`SyncSchema` 会在启动时自动补齐,无需在业务 DSL 中显式定义。
|
||||||
|
- **自动化 Schema 修复**: 修复了通过 API 创建表时缺少 `id` 字段导致的同步失败问题;`reconstructAndSyncSchema` 现在会自动为所有表补齐 `id c10 PK` 主键。
|
||||||
|
- **全域行级权限增强**: 行级权限检查逻辑现已扩展至所有包含 `creator` 字段的表(不仅限于系统表),通过 `Auth(userID)` 创建的实例在插入新记录时会自动填充 `creator`。
|
||||||
|
- **元数据安全性提升**: `_Field` 和 `_Policy` 现在也包含 `creator` 字段,支持对元数据本身的行级访问控制。
|
||||||
|
- **测试套件重构**: 全面重新设计了测试用例,覆盖了从零启动、API 驱动的架构变更、影子删除、策略授权及 Hook 触发等全量场景。
|
||||||
|
|
||||||
## v1.0.0
|
## v1.0.0
|
||||||
- **初始化重构迁移**: 剥离自 `knowbase/internal/db` 作为纯净独立包。
|
- **初始化重构迁移**: 剥离自 `knowbase/internal/db` 作为纯净独立包。
|
||||||
- **动态 Schema 管理**: 保留动态解析与同步,并封装独立的缓存与 Hook 事件触发逻辑。
|
- **动态 Schema 管理**: 保留动态解析与同步,并封装独立的缓存与 Hook 事件触发逻辑。
|
||||||
|
|||||||
96
db.go
96
db.go
@ -12,6 +12,33 @@ import (
|
|||||||
|
|
||||||
const SystemUserID = "_system"
|
const SystemUserID = "_system"
|
||||||
|
|
||||||
|
const SystemSchema = `
|
||||||
|
== System ==
|
||||||
|
_Table SD // 核心表:存储所有表的元数据
|
||||||
|
id c10 PK
|
||||||
|
name v64 U // 表名
|
||||||
|
memo t // 备注
|
||||||
|
createTime bi // 创建时间
|
||||||
|
creator v64 // 创建者
|
||||||
|
|
||||||
|
_Field SD // 核心表:存储所有字段的元数据
|
||||||
|
id c10 PK
|
||||||
|
tableId c10 I // 所属表 ID
|
||||||
|
name v64 // 字段名
|
||||||
|
type v32 // 字段类型
|
||||||
|
isIndex b // 是否索引
|
||||||
|
memo t // 备注
|
||||||
|
createTime bi // 创建时间
|
||||||
|
creator v64 // 创建者
|
||||||
|
|
||||||
|
_Policy SD // 核心表:访问策略
|
||||||
|
subject v64 I // 主体 (UserID 或 Role)
|
||||||
|
action v32 I // 动作
|
||||||
|
resource v128 I // 资源 (Table 或 Record ID)
|
||||||
|
effect v16 // allow 或 deny
|
||||||
|
creator v64 // 创建者
|
||||||
|
`
|
||||||
|
|
||||||
type Hooks struct {
|
type Hooks struct {
|
||||||
OnCreatedTable func(tableName string, record map[string]any)
|
OnCreatedTable func(tableName string, record map[string]any)
|
||||||
OnRemovedTable func(tableName string)
|
OnRemovedTable func(tableName string)
|
||||||
@ -52,22 +79,26 @@ func (d *TableDB) Auth(userID string) *App {
|
|||||||
|
|
||||||
// SyncSchema automatically applies the DSL schema to the underlying database.
|
// SyncSchema automatically applies the DSL schema to the underlying database.
|
||||||
func (d *TableDB) SyncSchema(schemaDSL string) error {
|
func (d *TableDB) SyncSchema(schemaDSL string) error {
|
||||||
// 1. Auto-inject autoIndex and ensure id c10 for all tables in DSL
|
// 1. Sync to actual DB
|
||||||
schemaDSL = injectUndergroundRules(schemaDSL)
|
// Underground rules (autoIndex, id normalization) are now handled internally by d.base.Sync
|
||||||
|
|
||||||
// 2. Sync to actual DB
|
finalDSL := schemaDSL
|
||||||
err := d.base.Sync(schemaDSL)
|
if !strings.Contains(schemaDSL, "_Table") {
|
||||||
|
finalDSL = SystemSchema + "\n" + schemaDSL
|
||||||
|
}
|
||||||
|
|
||||||
|
err := d.base.Sync(finalDSL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Update _Table and _Field metadata
|
// 2. Update _Table and _Field metadata
|
||||||
res := d.base.Query("SELECT name FROM sqlite_master WHERE type='table' AND name='_Table'")
|
res := d.base.Query("SELECT name FROM sqlite_master WHERE type='table' AND name='_Table'")
|
||||||
if d.base.Config.Type == "mysql" {
|
if d.base.Config.Type == "mysql" {
|
||||||
res = d.base.Query("SELECT TABLE_NAME name FROM information_schema.TABLES WHERE TABLE_SCHEMA=? AND TABLE_NAME='_Table'", d.base.Config.DB)
|
res = d.base.Query("SELECT TABLE_NAME name FROM information_schema.TABLES WHERE TABLE_SCHEMA=? AND TABLE_NAME='_Table'", d.base.Config.DB)
|
||||||
}
|
}
|
||||||
if res.Error == nil && res.MapOnR1()["name"] != nil {
|
if res.Error == nil && res.MapOnR1()["name"] != nil {
|
||||||
groups := db.ParseSchema(schemaDSL)
|
groups := db.ParseSchema(finalDSL)
|
||||||
for _, group := range groups {
|
for _, group := range groups {
|
||||||
for _, table := range group.Tables {
|
for _, table := range group.Tables {
|
||||||
// Upsert _Table
|
// Upsert _Table
|
||||||
@ -112,61 +143,10 @@ func (d *TableDB) SyncSchema(schemaDSL string) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Reload cache
|
// 3. Reload cache
|
||||||
return GlobalCache.Load(d)
|
return GlobalCache.Load(d)
|
||||||
}
|
}
|
||||||
|
|
||||||
func injectUndergroundRules(dsl string) string {
|
|
||||||
lines := strings.Split(dsl, "\n")
|
|
||||||
var result []string
|
|
||||||
var currentTable string
|
|
||||||
var hasAutoIndex bool
|
|
||||||
|
|
||||||
for i := 0; i < len(lines); i++ {
|
|
||||||
line := lines[i]
|
|
||||||
trimmed := strings.TrimSpace(line)
|
|
||||||
if trimmed == "" || strings.HasPrefix(trimmed, "#") || strings.HasPrefix(trimmed, "==") {
|
|
||||||
if currentTable != "" && !hasAutoIndex {
|
|
||||||
result = append(result, " autoIndex bi AI")
|
|
||||||
}
|
|
||||||
result = append(result, line)
|
|
||||||
currentTable = ""
|
|
||||||
hasAutoIndex = false
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if !strings.HasPrefix(line, " ") && !strings.HasPrefix(line, "\t") {
|
|
||||||
if currentTable != "" && !hasAutoIndex {
|
|
||||||
result = append(result, " autoIndex bi AI")
|
|
||||||
}
|
|
||||||
currentTable = trimmed
|
|
||||||
hasAutoIndex = false
|
|
||||||
result = append(result, line)
|
|
||||||
} else {
|
|
||||||
if strings.Contains(trimmed, "autoIndex") {
|
|
||||||
hasAutoIndex = true
|
|
||||||
}
|
|
||||||
if strings.HasPrefix(trimmed, "id ") {
|
|
||||||
if !strings.Contains(trimmed, "c10") {
|
|
||||||
newField := " id c10 U"
|
|
||||||
if strings.Contains(line, "//") {
|
|
||||||
newField += " //" + strings.SplitN(line, "//", 2)[1]
|
|
||||||
}
|
|
||||||
line = newField
|
|
||||||
} else if strings.Contains(trimmed, "PK") {
|
|
||||||
line = strings.Replace(line, "PK", "U", 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
result = append(result, line)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if currentTable != "" && !hasAutoIndex {
|
|
||||||
result = append(result, " autoIndex bi AI")
|
|
||||||
}
|
|
||||||
|
|
||||||
return strings.Join(result, "\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Table returns an AI-friendly interface for multi-dimensional operations on a specific table.
|
// Table returns an AI-friendly interface for multi-dimensional operations on a specific table.
|
||||||
func (d *TableDB) Table(name string) *Table {
|
func (d *TableDB) Table(name string) *Table {
|
||||||
return NewTable(name, d)
|
return NewTable(name, d)
|
||||||
|
|||||||
80
table.go
80
table.go
@ -52,9 +52,28 @@ func NewTable(name string, app *TableDB) *Table {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (t *Table) checkAuth(id string) error {
|
func (t *Table) checkAuth(id string) error {
|
||||||
if t.userID == "_system" || !strings.HasPrefix(t.Name, "_") {
|
if t.userID == "_system" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if table has 'creator' field
|
||||||
|
hasCreator := false
|
||||||
|
tableRec := GlobalCache.GetTable(t.Name)
|
||||||
|
if tableRec != nil {
|
||||||
|
tid := cast.String(tableRec["id"])
|
||||||
|
fields := GlobalCache.GetFields(tid)
|
||||||
|
for _, f := range fields {
|
||||||
|
if f.Name == "creator" {
|
||||||
|
hasCreator = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !hasCreator && !strings.HasPrefix(t.Name, "_") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
if t.Name == "_Policy" || t.Name == "_Backup" {
|
if t.Name == "_Policy" || t.Name == "_Backup" {
|
||||||
return fmt.Errorf("permission denied for %s", t.Name)
|
return fmt.Errorf("permission denied for %s", t.Name)
|
||||||
}
|
}
|
||||||
@ -87,7 +106,25 @@ func (t *Table) appendAuthAndConstraint(whereStr string, args []any) (string, []
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if t.userID != "_system" && strings.HasPrefix(t.Name, "_") {
|
if t.userID == "_system" {
|
||||||
|
return whereStr, args, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if table has 'creator' field
|
||||||
|
hasCreator := false
|
||||||
|
tableRec := GlobalCache.GetTable(t.Name)
|
||||||
|
if tableRec != nil {
|
||||||
|
tid := cast.String(tableRec["id"])
|
||||||
|
fields := GlobalCache.GetFields(tid)
|
||||||
|
for _, f := range fields {
|
||||||
|
if f.Name == "creator" {
|
||||||
|
hasCreator = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if hasCreator || strings.HasPrefix(t.Name, "_") {
|
||||||
if t.Name == "_Policy" || t.Name == "_Backup" {
|
if t.Name == "_Policy" || t.Name == "_Backup" {
|
||||||
return "", nil, fmt.Errorf("permission denied for %s", t.Name)
|
return "", nil, fmt.Errorf("permission denied for %s", t.Name)
|
||||||
}
|
}
|
||||||
@ -116,6 +153,13 @@ func (t *Table) reconstructAndSyncSchema() error {
|
|||||||
for _, tbl := range tables {
|
for _, tbl := range tables {
|
||||||
name := cast.String(tbl["name"])
|
name := cast.String(tbl["name"])
|
||||||
if name == "" { continue }
|
if name == "" { continue }
|
||||||
|
|
||||||
|
tid := cast.String(tbl["id"])
|
||||||
|
tblFields := fieldMap[tid]
|
||||||
|
if len(tblFields) == 0 {
|
||||||
|
continue // Skip tables with no fields to avoid SQL errors
|
||||||
|
}
|
||||||
|
|
||||||
memo := cast.String(tbl["memo"])
|
memo := cast.String(tbl["memo"])
|
||||||
sb.WriteString(name + " SD")
|
sb.WriteString(name + " SD")
|
||||||
if memo != "" {
|
if memo != "" {
|
||||||
@ -123,8 +167,18 @@ func (t *Table) reconstructAndSyncSchema() error {
|
|||||||
}
|
}
|
||||||
sb.WriteString("\n")
|
sb.WriteString("\n")
|
||||||
|
|
||||||
tid := cast.String(tbl["id"])
|
hasID := false
|
||||||
for _, f := range fieldMap[tid] {
|
for _, f := range tblFields {
|
||||||
|
if cast.String(f["name"]) == "id" {
|
||||||
|
hasID = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !hasID {
|
||||||
|
sb.WriteString(" id c10 PK\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, f := range tblFields {
|
||||||
fname := cast.String(f["name"])
|
fname := cast.String(f["name"])
|
||||||
ftype := cast.String(f["type"])
|
ftype := cast.String(f["type"])
|
||||||
if ftype == "" { ftype = "v255" }
|
if ftype == "" { ftype = "v255" }
|
||||||
@ -197,12 +251,28 @@ func (t *Table) Set(data any) error {
|
|||||||
|
|
||||||
var err error
|
var err error
|
||||||
if isInsert {
|
if isInsert {
|
||||||
if t.userID != "_system" && strings.HasPrefix(t.Name, "_") {
|
if t.userID != "_system" {
|
||||||
|
// Check if table has 'creator' field
|
||||||
|
hasCreator := false
|
||||||
|
tableRec := GlobalCache.GetTable(t.Name)
|
||||||
|
if tableRec != nil {
|
||||||
|
tid := cast.String(tableRec["id"])
|
||||||
|
fields := GlobalCache.GetFields(tid)
|
||||||
|
for _, f := range fields {
|
||||||
|
if f.Name == "creator" {
|
||||||
|
hasCreator = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if hasCreator || strings.HasPrefix(t.Name, "_") {
|
||||||
if t.Name == "_Policy" || t.Name == "_Backup" {
|
if t.Name == "_Policy" || t.Name == "_Backup" {
|
||||||
return fmt.Errorf("permission denied for %s", t.Name)
|
return fmt.Errorf("permission denied for %s", t.Name)
|
||||||
}
|
}
|
||||||
record["creator"] = t.userID
|
record["creator"] = t.userID
|
||||||
}
|
}
|
||||||
|
}
|
||||||
err = t.db.Insert(t.Name, record).Error
|
err = t.db.Insert(t.Name, record).Error
|
||||||
} else {
|
} else {
|
||||||
err = t.db.Update(t.Name, record, "id = ?", cast.String(record["id"])).Error
|
err = t.db.Update(t.Name, record, "id = ?", cast.String(record["id"])).Error
|
||||||
|
|||||||
571
tableDB_test.go
571
tableDB_test.go
@ -1,315 +1,332 @@
|
|||||||
package tableDB
|
package tableDB
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"apigo.cc/go/cast"
|
||||||
"apigo.cc/go/log"
|
"apigo.cc/go/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestSQLInjection(t *testing.T) {
|
func setupDB(t *testing.T, dbFile string) *TableDB {
|
||||||
logger := log.DefaultLogger
|
_ = os.Remove(dbFile)
|
||||||
dbFile := "test_injection.db"
|
|
||||||
os.Remove(dbFile)
|
|
||||||
defer os.Remove(dbFile)
|
|
||||||
|
|
||||||
dbInst := GetDB("sqlite://"+dbFile, logger)
|
|
||||||
|
|
||||||
schema := `
|
|
||||||
== InjectionGroup ==
|
|
||||||
_Table SD
|
|
||||||
id c10 PK
|
|
||||||
name v64 U
|
|
||||||
memo t
|
|
||||||
createTime bi
|
|
||||||
creator v64
|
|
||||||
|
|
||||||
_Field SD
|
|
||||||
id c10 PK
|
|
||||||
tableId c10 I
|
|
||||||
name v64
|
|
||||||
type v32
|
|
||||||
isIndex b
|
|
||||||
memo t
|
|
||||||
createTime bi
|
|
||||||
|
|
||||||
_Policy SD
|
|
||||||
subject v64 I
|
|
||||||
action v32 I
|
|
||||||
resource v128 I
|
|
||||||
effect v16
|
|
||||||
|
|
||||||
== Test ==
|
|
||||||
users_inj SD
|
|
||||||
id c10 PK
|
|
||||||
name v50 U
|
|
||||||
secret t
|
|
||||||
`
|
|
||||||
err := dbInst.SyncSchema(schema)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to sync schema: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
appAdmin := dbInst.Auth("admin")
|
|
||||||
table := appAdmin.Table("users_inj")
|
|
||||||
_ = table.Set(map[string]any{"name": "Alice", "secret": "top-secret-123"})
|
|
||||||
|
|
||||||
// Attempt SQL injection via Table name
|
|
||||||
req1 := QueryRequest{
|
|
||||||
Table: "users_inj` --",
|
|
||||||
}
|
|
||||||
_, _, err = dbInst.BuildQuery(req1)
|
|
||||||
if err == nil {
|
|
||||||
t.Errorf("Expected error for invalid table name with injection")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Attempt SQL injection via Field name
|
|
||||||
req2 := QueryRequest{
|
|
||||||
Table: "users_inj",
|
|
||||||
Select: []string{"name`, secret AS name `"},
|
|
||||||
}
|
|
||||||
_, _, err = dbInst.BuildQuery(req2)
|
|
||||||
if err == nil {
|
|
||||||
t.Errorf("Expected error for invalid field name with injection")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Attempt SQL injection via Join Table
|
|
||||||
req3 := QueryRequest{
|
|
||||||
Table: "users_inj",
|
|
||||||
Joins: []JoinConfig{
|
|
||||||
{Table: "users_inj` --", On: "1=1"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
_, _, err = dbInst.BuildQuery(req3)
|
|
||||||
if err == nil {
|
|
||||||
t.Errorf("Expected error for invalid join table name")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Attempt SQL injection via OrderBy
|
|
||||||
req4 := QueryRequest{
|
|
||||||
Table: "users_inj",
|
|
||||||
OrderBy: "name; DROP TABLE users_inj; --",
|
|
||||||
}
|
|
||||||
_, _, err = dbInst.BuildQuery(req4)
|
|
||||||
if err == nil {
|
|
||||||
t.Errorf("Expected error for invalid order by with injection")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTableOperationsAndHooks(t *testing.T) {
|
|
||||||
logger := log.DefaultLogger
|
logger := log.DefaultLogger
|
||||||
logger.SetLevel(log.ERROR)
|
logger.SetLevel(log.ERROR)
|
||||||
os.Remove("test_ops.db")
|
dbInst := GetDB("sqlite://"+dbFile, logger)
|
||||||
defer os.Remove("test_ops.db")
|
return dbInst
|
||||||
|
}
|
||||||
|
|
||||||
dbInst := GetDB("sqlite://test_ops.db", logger)
|
func TestBootstrapAndSync(t *testing.T) {
|
||||||
|
dbFile := "test_bootstrap.db"
|
||||||
|
dbInst := setupDB(t, dbFile)
|
||||||
|
defer os.Remove(dbFile)
|
||||||
|
|
||||||
var hookUpdatedRowsCount int
|
// 1. Bootstrap: Sync empty DSL, should still create system tables
|
||||||
var hookRemovedRowsCount int
|
err := dbInst.SyncSchema("")
|
||||||
var hookUpdatingRowCalled bool
|
if err != nil {
|
||||||
|
t.Fatalf("Bootstrap failed: %v", err)
|
||||||
dbInst.Hooks.OnUpdatingRow = func(tableName string, row map[string]any) error {
|
|
||||||
hookUpdatingRowCalled = true
|
|
||||||
if tableName == "users_ops" {
|
|
||||||
row["memo"] = "hooked"
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
dbInst.Hooks.OnUpdatedRows = func(tableName string, count int) {
|
|
||||||
hookUpdatedRowsCount += count
|
|
||||||
}
|
|
||||||
dbInst.Hooks.OnRemovedRows = func(tableName string, ids []string) {
|
|
||||||
hookRemovedRowsCount += len(ids)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
schema := `
|
// Verify system tables in cache
|
||||||
== TestGroup ==
|
if GlobalCache.GetTable("_Table") == nil {
|
||||||
_Table SD
|
t.Errorf("_Table not found in cache after bootstrap")
|
||||||
|
}
|
||||||
|
if GlobalCache.GetTable("_Field") == nil {
|
||||||
|
t.Errorf("_Field not found in cache after bootstrap")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Sync business schema
|
||||||
|
businessDSL := `
|
||||||
|
== Business ==
|
||||||
|
users SD
|
||||||
id c10 PK
|
id c10 PK
|
||||||
name v64 U
|
name v64 U
|
||||||
memo t
|
|
||||||
createTime bi
|
|
||||||
creator v64
|
|
||||||
|
|
||||||
_Field SD
|
|
||||||
id c10 PK
|
|
||||||
tableId c10 I
|
|
||||||
name v64
|
|
||||||
type v32
|
|
||||||
isIndex b
|
|
||||||
memo t
|
|
||||||
createTime bi
|
|
||||||
|
|
||||||
_Policy SD
|
|
||||||
subject v64 I
|
|
||||||
action v32 I
|
|
||||||
resource v128 I
|
|
||||||
effect v16
|
|
||||||
|
|
||||||
== Test ==
|
|
||||||
users_ops SD
|
|
||||||
id c10 PK
|
|
||||||
name v50 U
|
|
||||||
age i
|
age i
|
||||||
status ti
|
|
||||||
memo t
|
|
||||||
`
|
`
|
||||||
err := dbInst.SyncSchema(schema)
|
err = dbInst.SyncSchema(businessDSL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to sync schema: %v", err)
|
t.Fatalf("Business sync failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
appAdmin := dbInst.Auth("admin")
|
// Verify business table
|
||||||
table := appAdmin.Table("users_ops")
|
if GlobalCache.GetTable("users") == nil {
|
||||||
|
t.Errorf("users table not found in cache after sync")
|
||||||
// Test Set (Insert)
|
|
||||||
err = table.Set(map[string]any{
|
|
||||||
"name": "Alice",
|
|
||||||
"age": 30,
|
|
||||||
"status": 1,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Set failed: %v", err)
|
|
||||||
}
|
}
|
||||||
|
fields := GlobalCache.GetValidFields("users")
|
||||||
if !hookUpdatingRowCalled {
|
if len(fields) < 3 { // id, name, age (and maybe others from SD)
|
||||||
t.Errorf("Expected OnUpdatingRow to be called")
|
t.Errorf("Expected at least 3 fields for users, got %v", fields)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if hookUpdatedRowsCount != 1 {
|
|
||||||
t.Errorf("Expected OnUpdatedRows to be 1, got %d", hookUpdatedRowsCount)
|
func TestAPIDrivenSchema(t *testing.T) {
|
||||||
}
|
dbFile := "test_api_schema.db"
|
||||||
|
dbInst := setupDB(t, dbFile)
|
||||||
// Test Set with explicit ID (Insert)
|
defer os.Remove(dbFile)
|
||||||
err = table.Set(map[string]any{
|
|
||||||
"id": "100",
|
_ = dbInst.SyncSchema("")
|
||||||
"name": "Bob",
|
|
||||||
"age": 25,
|
app := dbInst.Auth("admin")
|
||||||
"status": 0,
|
|
||||||
})
|
// 1. Create table via _Table API
|
||||||
if err != nil {
|
err := app.Table("_Table").Set(map[string]any{
|
||||||
t.Fatalf("Set with ID failed: %v", err)
|
"name": "tasks",
|
||||||
}
|
"memo": "Task list",
|
||||||
|
})
|
||||||
// Test Get
|
if err != nil {
|
||||||
record, err := table.Get("100")
|
t.Fatalf("Failed to create table via API: %v", err)
|
||||||
if err != nil || record == nil {
|
}
|
||||||
t.Fatalf("Get failed: %v", err)
|
|
||||||
}
|
// Get tid
|
||||||
if record["name"] != "Bob" {
|
tbl, _ := app.Table("_Table").List(map[string]any{"name": "tasks"})
|
||||||
t.Fatalf("Expected name Bob, got %v", record["name"])
|
if len(tbl) == 0 {
|
||||||
}
|
t.Fatalf("Table tasks not found in _Table")
|
||||||
if record["memo"] != "hooked" {
|
}
|
||||||
t.Fatalf("Expected memo hooked, got %v", record["memo"])
|
tid := cast.String(tbl[0]["id"])
|
||||||
}
|
|
||||||
|
// 2. Add fields via _Field API
|
||||||
// Test QueryRequest
|
_ = app.Table("_Field").Set(map[string]any{
|
||||||
queryReq := QueryRequest{
|
"tableId": tid,
|
||||||
Table: "users_ops",
|
"name": "title",
|
||||||
Where: "age > ?",
|
"type": "v100",
|
||||||
Args: []any{20},
|
"memo": "Task title",
|
||||||
Limit: 10,
|
})
|
||||||
}
|
_ = app.Table("_Field").Set(map[string]any{
|
||||||
res, err := dbInst.Query(queryReq)
|
"tableId": tid,
|
||||||
if err != nil {
|
"name": "done",
|
||||||
t.Fatalf("QueryRequest failed: %v", err)
|
"type": "b",
|
||||||
}
|
"isIndex": 1,
|
||||||
if len(res) != 2 {
|
})
|
||||||
t.Fatalf("Expected 2 results from QueryRequest, got %d", len(res))
|
|
||||||
}
|
// 3. Verify table works
|
||||||
|
taskTable := app.Table("tasks")
|
||||||
// Test cache and _Field
|
err = taskTable.Set(map[string]any{
|
||||||
fields, err := table.Fields()
|
"title": "Fix tests",
|
||||||
if err != nil {
|
"done": false,
|
||||||
t.Fatalf("Fields() failed: %v", err)
|
})
|
||||||
}
|
if err != nil {
|
||||||
if len(fields) == 0 {
|
t.Fatalf("Failed to insert into tasks table: %v", err)
|
||||||
t.Fatalf("Expected fields metadata, got empty")
|
}
|
||||||
}
|
|
||||||
hasAge := false
|
res, _ := taskTable.List(nil)
|
||||||
for _, f := range fields {
|
if len(res) != 1 || res[0]["title"] != "Fix tests" {
|
||||||
if f.Name == "age" {
|
t.Errorf("Query from tasks failed: %v", res)
|
||||||
hasAge = true
|
}
|
||||||
break
|
}
|
||||||
}
|
|
||||||
}
|
func TestDataOperations(t *testing.T) {
|
||||||
if !hasAge {
|
dbFile := "test_data_ops.db"
|
||||||
t.Fatalf("Field 'age' not found in metadata")
|
dbInst := setupDB(t, dbFile)
|
||||||
}
|
defer os.Remove(dbFile)
|
||||||
|
|
||||||
// Test List
|
_ = dbInst.SyncSchema(`
|
||||||
list, err := table.List(map[string]any{"age >": 20})
|
== Test ==
|
||||||
if err != nil {
|
products SD
|
||||||
t.Fatalf("List failed: %v", err)
|
id c10 PK
|
||||||
}
|
name v64 U
|
||||||
if len(list) != 2 {
|
price i
|
||||||
t.Fatalf("Expected 2 results from List, got %d", len(list))
|
`)
|
||||||
}
|
|
||||||
|
app := dbInst.Auth("admin")
|
||||||
// Test Count
|
table := app.Table("products")
|
||||||
count, err := table.Count(map[string]any{"age >": 20})
|
|
||||||
if err != nil {
|
// Create
|
||||||
t.Fatalf("Count failed: %v", err)
|
_ = table.Set(map[string]any{"name": "Laptop", "price": 1000})
|
||||||
}
|
_ = table.Set(map[string]any{"id": "p2", "name": "Mouse", "price": 50})
|
||||||
if count != 2 {
|
|
||||||
t.Fatalf("Expected count 2, got %d", count)
|
// Read
|
||||||
}
|
p1, _ := table.Get("p2")
|
||||||
|
if p1 == nil || p1["name"] != "Mouse" {
|
||||||
// Test Remove
|
t.Errorf("Get p2 failed")
|
||||||
err = table.Remove("100")
|
}
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Remove failed: %v", err)
|
// Update
|
||||||
}
|
_ = table.Set(map[string]any{"id": "p2", "price": 45})
|
||||||
record, err = table.Get("100")
|
p1_updated, _ := table.Get("p2")
|
||||||
if record != nil {
|
if cast.Int(p1_updated["price"]) != 45 {
|
||||||
t.Fatalf("Expected nil after removal, got %v", record)
|
t.Errorf("Update p2 failed, got %v", p1_updated["price"])
|
||||||
}
|
}
|
||||||
|
|
||||||
if hookRemovedRowsCount != 1 {
|
// List & Filter
|
||||||
t.Errorf("Expected hookRemovedRowsCount to be 1, got %d", hookRemovedRowsCount)
|
list, _ := table.List(map[string]any{"price >": 100})
|
||||||
|
if len(list) != 1 || list[0]["name"] != "Laptop" {
|
||||||
|
t.Errorf("List/Filter failed: %v", list)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count
|
||||||
|
cnt, _ := table.Count(nil)
|
||||||
|
if cnt != 2 {
|
||||||
|
t.Errorf("Count failed: %d", cnt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete (Shadow Delete)
|
||||||
|
_ = table.Remove("p2")
|
||||||
|
p1_removed, _ := table.Get("p2")
|
||||||
|
if p1_removed != nil {
|
||||||
|
t.Errorf("Expected p2 to be removed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify shadow delete (actual table has _deleted suffix)
|
||||||
|
res := dbInst.Base().Query("SELECT name FROM sqlite_master WHERE name='products_deleted'")
|
||||||
|
if res.MapOnR1()["name"] == nil {
|
||||||
|
t.Errorf("Shadow delete table products_deleted not found")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPermissionsAndAuth(t *testing.T) {
|
||||||
|
dbFile := "test_auth.db"
|
||||||
|
dbInst := setupDB(t, dbFile)
|
||||||
|
defer os.Remove(dbFile)
|
||||||
|
|
||||||
|
_ = dbInst.SyncSchema(`
|
||||||
|
== Secret ==
|
||||||
|
secrets SD
|
||||||
|
id c10 PK
|
||||||
|
content t
|
||||||
|
creator v64
|
||||||
|
`)
|
||||||
|
|
||||||
|
user1 := dbInst.Auth("user1")
|
||||||
|
user2 := dbInst.Auth("user2")
|
||||||
|
|
||||||
|
// user1 creates a secret
|
||||||
|
_ = user1.Table("secrets").Set(map[string]any{"id": "s1", "content": "user1-secret"})
|
||||||
|
|
||||||
|
// user2 tries to get it -> should fail or return nil depending on implementation
|
||||||
|
// Current implementation: checkAuth returns error if no permission
|
||||||
|
_, err := user2.Table("secrets").Get("s1")
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("user2 should not have permission to get user1 secret")
|
||||||
|
}
|
||||||
|
|
||||||
|
// admin gets it -> _system or admin should have permission?
|
||||||
|
// Implementation says t.userID == "_system" bypasses.
|
||||||
|
sys := dbInst.Auth("_system")
|
||||||
|
s, err := sys.Table("secrets").Get("s1")
|
||||||
|
if err != nil || s == nil {
|
||||||
|
t.Errorf("system should have permission: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test _Policy
|
||||||
|
// allow user2 to see s1
|
||||||
|
err = sys.Table("_Policy").Set(map[string]any{
|
||||||
|
"subject": "user2",
|
||||||
|
"action": "read",
|
||||||
|
"resource": "s1",
|
||||||
|
"effect": "allow",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to set policy: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now user2 should be able to get it
|
||||||
|
s, err = user2.Table("secrets").Get("s1")
|
||||||
|
if err != nil || s == nil {
|
||||||
|
t.Errorf("user2 should now have permission via policy: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHooks(t *testing.T) {
|
||||||
|
dbFile := "test_hooks.db"
|
||||||
|
dbInst := setupDB(t, dbFile)
|
||||||
|
defer os.Remove(dbFile)
|
||||||
|
|
||||||
|
var tableCreated string
|
||||||
|
var rowsUpdated int
|
||||||
|
|
||||||
|
dbInst.Hooks.OnCreatedTable = func(tableName string, record map[string]any) {
|
||||||
|
tableCreated = tableName
|
||||||
|
}
|
||||||
|
dbInst.Hooks.OnUpdatedRows = func(tableName string, count int) {
|
||||||
|
rowsUpdated += count
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = dbInst.SyncSchema("")
|
||||||
|
app := dbInst.Auth("admin")
|
||||||
|
|
||||||
|
// Trigger OnCreatedTable
|
||||||
|
_ = app.Table("_Table").Set(map[string]any{"name": "hook_test"})
|
||||||
|
if tableCreated != "hook_test" {
|
||||||
|
t.Errorf("OnCreatedTable hook failed, got %s", tableCreated)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add a field to ensure table exists
|
||||||
|
tbl, _ := app.Table("_Table").List(map[string]any{"name": "hook_test"})
|
||||||
|
tid := cast.String(tbl[0]["id"])
|
||||||
|
_ = app.Table("_Field").Set(map[string]any{"tableId": tid, "name": "val", "type": "v10"})
|
||||||
|
|
||||||
|
// Trigger OnUpdatedRows
|
||||||
|
_ = app.Table("hook_test").Set(map[string]any{"id": "1", "val": "a"})
|
||||||
|
if rowsUpdated != 1 {
|
||||||
|
t.Errorf("OnUpdatedRows hook failed, got %d", rowsUpdated)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQueryValidationAndInjection(t *testing.T) {
|
||||||
|
dbFile := "test_query.db"
|
||||||
|
dbInst := setupDB(t, dbFile)
|
||||||
|
defer os.Remove(dbFile)
|
||||||
|
|
||||||
|
_ = dbInst.SyncSchema(`
|
||||||
|
== Test ==
|
||||||
|
safe_table SD
|
||||||
|
id c10 PK
|
||||||
|
name v64
|
||||||
|
`)
|
||||||
|
|
||||||
|
// Valid query
|
||||||
|
req := QueryRequest{
|
||||||
|
Table: "safe_table",
|
||||||
|
Select: []string{"id", "name"},
|
||||||
|
OrderBy: "name DESC",
|
||||||
|
}
|
||||||
|
_, _, err := dbInst.BuildQuery(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Valid query failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invalid table
|
||||||
|
req.Table = "invalid_table"
|
||||||
|
_, _, err = dbInst.BuildQuery(req)
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("Should fail for invalid table")
|
||||||
|
}
|
||||||
|
|
||||||
|
// SQL Injection in Select
|
||||||
|
req.Table = "safe_table"
|
||||||
|
req.Select = []string{"name` FROM safe_table; --"}
|
||||||
|
_, _, err = dbInst.BuildQuery(req)
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("Should fail for injection in Select")
|
||||||
|
}
|
||||||
|
|
||||||
|
// SQL Injection in OrderBy
|
||||||
|
req.Select = []string{"name"}
|
||||||
|
req.OrderBy = "name; DROP TABLE safe_table"
|
||||||
|
_, _, err = dbInst.BuildQuery(req)
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("Should fail for injection in OrderBy")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
|
||||||
func BenchmarkTableSet(b *testing.B) {
|
func BenchmarkTableSet(b *testing.B) {
|
||||||
logger := log.DefaultLogger
|
logger := log.DefaultLogger
|
||||||
logger.SetLevel(log.ERROR)
|
logger.SetLevel(log.ERROR)
|
||||||
os.Remove("bench_ops.db")
|
dbFile := fmt.Sprintf("bench_ops_%d.db", time.Now().UnixNano())
|
||||||
defer os.Remove("bench_ops.db")
|
defer os.Remove(dbFile)
|
||||||
|
|
||||||
dbInst := GetDB("sqlite://bench_ops.db", logger)
|
dbInst := GetDB("sqlite://"+dbFile, logger)
|
||||||
schema := `
|
_ = dbInst.SyncSchema("bench_ops SD\n id c10 PK\n name v50 U\n val i")
|
||||||
== TestGroup ==
|
|
||||||
_Table SD
|
|
||||||
id c10 PK
|
|
||||||
name v64 U
|
|
||||||
|
|
||||||
_Field SD
|
app := dbInst.Auth("admin")
|
||||||
id c10 PK
|
table := app.Table("bench_ops")
|
||||||
tableId c10 I
|
|
||||||
name v64
|
|
||||||
type v32
|
|
||||||
|
|
||||||
_Policy SD
|
|
||||||
subject v64 I
|
|
||||||
|
|
||||||
== Test ==
|
|
||||||
bench_ops SD
|
|
||||||
id c10 PK
|
|
||||||
name v50 U
|
|
||||||
val i
|
|
||||||
`
|
|
||||||
_ = dbInst.SyncSchema(schema)
|
|
||||||
|
|
||||||
appAdmin := dbInst.Auth("admin")
|
|
||||||
table := appAdmin.Table("bench_ops")
|
|
||||||
|
|
||||||
b.ResetTimer()
|
b.ResetTimer()
|
||||||
for i := 0; i < b.N; i++ {
|
for i := 0; i < b.N; i++ {
|
||||||
table.Set(map[string]any{
|
_ = table.Set(map[string]any{
|
||||||
"name": cast.String(i),
|
"name": "user_" + cast.String(i),
|
||||||
"val": i,
|
"val": i,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
*/
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user