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
|
||||
|
||||
## 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
|
||||
- **初始化重构迁移**: 剥离自 `knowbase/internal/db` 作为纯净独立包。
|
||||
- **动态 Schema 管理**: 保留动态解析与同步,并封装独立的缓存与 Hook 事件触发逻辑。
|
||||
|
||||
96
db.go
96
db.go
@ -12,6 +12,33 @@ import (
|
||||
|
||||
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 {
|
||||
OnCreatedTable func(tableName string, record map[string]any)
|
||||
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.
|
||||
func (d *TableDB) SyncSchema(schemaDSL string) error {
|
||||
// 1. Auto-inject autoIndex and ensure id c10 for all tables in DSL
|
||||
schemaDSL = injectUndergroundRules(schemaDSL)
|
||||
// 1. Sync to actual DB
|
||||
// Underground rules (autoIndex, id normalization) are now handled internally by d.base.Sync
|
||||
|
||||
finalDSL := schemaDSL
|
||||
if !strings.Contains(schemaDSL, "_Table") {
|
||||
finalDSL = SystemSchema + "\n" + schemaDSL
|
||||
}
|
||||
|
||||
// 2. Sync to actual DB
|
||||
err := d.base.Sync(schemaDSL)
|
||||
err := d.base.Sync(finalDSL)
|
||||
if err != nil {
|
||||
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'")
|
||||
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)
|
||||
}
|
||||
if res.Error == nil && res.MapOnR1()["name"] != nil {
|
||||
groups := db.ParseSchema(schemaDSL)
|
||||
groups := db.ParseSchema(finalDSL)
|
||||
for _, group := range groups {
|
||||
for _, table := range group.Tables {
|
||||
// Upsert _Table
|
||||
@ -112,61 +143,10 @@ func (d *TableDB) SyncSchema(schemaDSL string) error {
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Reload cache
|
||||
// 3. Reload cache
|
||||
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.
|
||||
func (d *TableDB) Table(name string) *Table {
|
||||
return NewTable(name, d)
|
||||
|
||||
86
table.go
86
table.go
@ -52,9 +52,28 @@ func NewTable(name string, app *TableDB) *Table {
|
||||
}
|
||||
|
||||
func (t *Table) checkAuth(id string) error {
|
||||
if t.userID == "_system" || !strings.HasPrefix(t.Name, "_") {
|
||||
if t.userID == "_system" {
|
||||
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" {
|
||||
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" {
|
||||
return "", nil, fmt.Errorf("permission denied for %s", t.Name)
|
||||
}
|
||||
@ -116,6 +153,13 @@ func (t *Table) reconstructAndSyncSchema() error {
|
||||
for _, tbl := range tables {
|
||||
name := cast.String(tbl["name"])
|
||||
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"])
|
||||
sb.WriteString(name + " SD")
|
||||
if memo != "" {
|
||||
@ -123,8 +167,18 @@ func (t *Table) reconstructAndSyncSchema() error {
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
|
||||
tid := cast.String(tbl["id"])
|
||||
for _, f := range fieldMap[tid] {
|
||||
hasID := false
|
||||
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"])
|
||||
ftype := cast.String(f["type"])
|
||||
if ftype == "" { ftype = "v255" }
|
||||
@ -197,11 +251,27 @@ func (t *Table) Set(data any) error {
|
||||
|
||||
var err error
|
||||
if isInsert {
|
||||
if t.userID != "_system" && strings.HasPrefix(t.Name, "_") {
|
||||
if t.Name == "_Policy" || t.Name == "_Backup" {
|
||||
return fmt.Errorf("permission denied for %s", 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" {
|
||||
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
|
||||
} else {
|
||||
|
||||
571
tableDB_test.go
571
tableDB_test.go
@ -1,315 +1,332 @@
|
||||
package tableDB
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"apigo.cc/go/cast"
|
||||
"apigo.cc/go/log"
|
||||
)
|
||||
|
||||
func TestSQLInjection(t *testing.T) {
|
||||
logger := log.DefaultLogger
|
||||
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) {
|
||||
func setupDB(t *testing.T, dbFile string) *TableDB {
|
||||
_ = os.Remove(dbFile)
|
||||
logger := log.DefaultLogger
|
||||
logger.SetLevel(log.ERROR)
|
||||
os.Remove("test_ops.db")
|
||||
defer os.Remove("test_ops.db")
|
||||
dbInst := GetDB("sqlite://"+dbFile, logger)
|
||||
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
|
||||
var hookRemovedRowsCount int
|
||||
var hookUpdatingRowCalled bool
|
||||
|
||||
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)
|
||||
// 1. Bootstrap: Sync empty DSL, should still create system tables
|
||||
err := dbInst.SyncSchema("")
|
||||
if err != nil {
|
||||
t.Fatalf("Bootstrap failed: %v", err)
|
||||
}
|
||||
|
||||
schema := `
|
||||
== TestGroup ==
|
||||
_Table SD
|
||||
// Verify system tables in cache
|
||||
if GlobalCache.GetTable("_Table") == nil {
|
||||
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
|
||||
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
|
||||
status ti
|
||||
memo t
|
||||
`
|
||||
err := dbInst.SyncSchema(schema)
|
||||
err = dbInst.SyncSchema(businessDSL)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to sync schema: %v", err)
|
||||
t.Fatalf("Business sync failed: %v", err)
|
||||
}
|
||||
|
||||
appAdmin := dbInst.Auth("admin")
|
||||
table := appAdmin.Table("users_ops")
|
||||
|
||||
// Test Set (Insert)
|
||||
err = table.Set(map[string]any{
|
||||
"name": "Alice",
|
||||
"age": 30,
|
||||
"status": 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Set failed: %v", err)
|
||||
// Verify business table
|
||||
if GlobalCache.GetTable("users") == nil {
|
||||
t.Errorf("users table not found in cache after sync")
|
||||
}
|
||||
|
||||
if !hookUpdatingRowCalled {
|
||||
t.Errorf("Expected OnUpdatingRow to be called")
|
||||
}
|
||||
|
||||
if hookUpdatedRowsCount != 1 {
|
||||
t.Errorf("Expected OnUpdatedRows to be 1, got %d", hookUpdatedRowsCount)
|
||||
}
|
||||
|
||||
// Test Set with explicit ID (Insert)
|
||||
err = table.Set(map[string]any{
|
||||
"id": "100",
|
||||
"name": "Bob",
|
||||
"age": 25,
|
||||
"status": 0,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Set with ID failed: %v", err)
|
||||
}
|
||||
|
||||
// Test Get
|
||||
record, err := table.Get("100")
|
||||
if err != nil || record == nil {
|
||||
t.Fatalf("Get failed: %v", err)
|
||||
}
|
||||
if record["name"] != "Bob" {
|
||||
t.Fatalf("Expected name Bob, got %v", record["name"])
|
||||
}
|
||||
if record["memo"] != "hooked" {
|
||||
t.Fatalf("Expected memo hooked, got %v", record["memo"])
|
||||
}
|
||||
|
||||
// Test QueryRequest
|
||||
queryReq := QueryRequest{
|
||||
Table: "users_ops",
|
||||
Where: "age > ?",
|
||||
Args: []any{20},
|
||||
Limit: 10,
|
||||
}
|
||||
res, err := dbInst.Query(queryReq)
|
||||
if err != nil {
|
||||
t.Fatalf("QueryRequest failed: %v", err)
|
||||
}
|
||||
if len(res) != 2 {
|
||||
t.Fatalf("Expected 2 results from QueryRequest, got %d", len(res))
|
||||
}
|
||||
|
||||
// Test cache and _Field
|
||||
fields, err := table.Fields()
|
||||
if err != nil {
|
||||
t.Fatalf("Fields() failed: %v", err)
|
||||
}
|
||||
if len(fields) == 0 {
|
||||
t.Fatalf("Expected fields metadata, got empty")
|
||||
}
|
||||
hasAge := false
|
||||
for _, f := range fields {
|
||||
if f.Name == "age" {
|
||||
hasAge = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasAge {
|
||||
t.Fatalf("Field 'age' not found in metadata")
|
||||
}
|
||||
|
||||
// Test List
|
||||
list, err := table.List(map[string]any{"age >": 20})
|
||||
if err != nil {
|
||||
t.Fatalf("List failed: %v", err)
|
||||
}
|
||||
if len(list) != 2 {
|
||||
t.Fatalf("Expected 2 results from List, got %d", len(list))
|
||||
}
|
||||
|
||||
// Test Count
|
||||
count, err := table.Count(map[string]any{"age >": 20})
|
||||
if err != nil {
|
||||
t.Fatalf("Count failed: %v", err)
|
||||
}
|
||||
if count != 2 {
|
||||
t.Fatalf("Expected count 2, got %d", count)
|
||||
}
|
||||
|
||||
// Test Remove
|
||||
err = table.Remove("100")
|
||||
if err != nil {
|
||||
t.Fatalf("Remove failed: %v", err)
|
||||
}
|
||||
record, err = table.Get("100")
|
||||
if record != nil {
|
||||
t.Fatalf("Expected nil after removal, got %v", record)
|
||||
}
|
||||
|
||||
if hookRemovedRowsCount != 1 {
|
||||
t.Errorf("Expected hookRemovedRowsCount to be 1, got %d", hookRemovedRowsCount)
|
||||
fields := GlobalCache.GetValidFields("users")
|
||||
if len(fields) < 3 { // id, name, age (and maybe others from SD)
|
||||
t.Errorf("Expected at least 3 fields for users, got %v", fields)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIDrivenSchema(t *testing.T) {
|
||||
dbFile := "test_api_schema.db"
|
||||
dbInst := setupDB(t, dbFile)
|
||||
defer os.Remove(dbFile)
|
||||
|
||||
_ = dbInst.SyncSchema("")
|
||||
|
||||
app := dbInst.Auth("admin")
|
||||
|
||||
// 1. Create table via _Table API
|
||||
err := app.Table("_Table").Set(map[string]any{
|
||||
"name": "tasks",
|
||||
"memo": "Task list",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create table via API: %v", err)
|
||||
}
|
||||
|
||||
// Get tid
|
||||
tbl, _ := app.Table("_Table").List(map[string]any{"name": "tasks"})
|
||||
if len(tbl) == 0 {
|
||||
t.Fatalf("Table tasks not found in _Table")
|
||||
}
|
||||
tid := cast.String(tbl[0]["id"])
|
||||
|
||||
// 2. Add fields via _Field API
|
||||
_ = app.Table("_Field").Set(map[string]any{
|
||||
"tableId": tid,
|
||||
"name": "title",
|
||||
"type": "v100",
|
||||
"memo": "Task title",
|
||||
})
|
||||
_ = app.Table("_Field").Set(map[string]any{
|
||||
"tableId": tid,
|
||||
"name": "done",
|
||||
"type": "b",
|
||||
"isIndex": 1,
|
||||
})
|
||||
|
||||
// 3. Verify table works
|
||||
taskTable := app.Table("tasks")
|
||||
err = taskTable.Set(map[string]any{
|
||||
"title": "Fix tests",
|
||||
"done": false,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to insert into tasks table: %v", err)
|
||||
}
|
||||
|
||||
res, _ := taskTable.List(nil)
|
||||
if len(res) != 1 || res[0]["title"] != "Fix tests" {
|
||||
t.Errorf("Query from tasks failed: %v", res)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDataOperations(t *testing.T) {
|
||||
dbFile := "test_data_ops.db"
|
||||
dbInst := setupDB(t, dbFile)
|
||||
defer os.Remove(dbFile)
|
||||
|
||||
_ = dbInst.SyncSchema(`
|
||||
== Test ==
|
||||
products SD
|
||||
id c10 PK
|
||||
name v64 U
|
||||
price i
|
||||
`)
|
||||
|
||||
app := dbInst.Auth("admin")
|
||||
table := app.Table("products")
|
||||
|
||||
// Create
|
||||
_ = table.Set(map[string]any{"name": "Laptop", "price": 1000})
|
||||
_ = table.Set(map[string]any{"id": "p2", "name": "Mouse", "price": 50})
|
||||
|
||||
// Read
|
||||
p1, _ := table.Get("p2")
|
||||
if p1 == nil || p1["name"] != "Mouse" {
|
||||
t.Errorf("Get p2 failed")
|
||||
}
|
||||
|
||||
// Update
|
||||
_ = table.Set(map[string]any{"id": "p2", "price": 45})
|
||||
p1_updated, _ := table.Get("p2")
|
||||
if cast.Int(p1_updated["price"]) != 45 {
|
||||
t.Errorf("Update p2 failed, got %v", p1_updated["price"])
|
||||
}
|
||||
|
||||
// List & Filter
|
||||
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) {
|
||||
logger := log.DefaultLogger
|
||||
logger.SetLevel(log.ERROR)
|
||||
os.Remove("bench_ops.db")
|
||||
defer os.Remove("bench_ops.db")
|
||||
dbFile := fmt.Sprintf("bench_ops_%d.db", time.Now().UnixNano())
|
||||
defer os.Remove(dbFile)
|
||||
|
||||
dbInst := GetDB("sqlite://bench_ops.db", logger)
|
||||
schema := `
|
||||
== TestGroup ==
|
||||
_Table SD
|
||||
id c10 PK
|
||||
name v64 U
|
||||
dbInst := GetDB("sqlite://"+dbFile, logger)
|
||||
_ = dbInst.SyncSchema("bench_ops SD\n id c10 PK\n name v50 U\n val i")
|
||||
|
||||
_Field SD
|
||||
id c10 PK
|
||||
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")
|
||||
app := dbInst.Auth("admin")
|
||||
table := app.Table("bench_ops")
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
table.Set(map[string]any{
|
||||
"name": cast.String(i),
|
||||
_ = table.Set(map[string]any{
|
||||
"name": "user_" + cast.String(i),
|
||||
"val": i,
|
||||
})
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user