diff --git a/Bell/README.md b/Bell/README.md index 072ad58..f4317e0 100644 --- a/Bell/README.md +++ b/Bell/README.md @@ -60,6 +60,12 @@ pnpm dev The web client listens on port 8083 in development and proxies `/api` to Bell only. The visible shell contains only Bell's product navigation; go-admin demonstration routes are neither registered nor served by the backend. +### Development-only synthetic events + +When `BELL_ENV=development` or `test`, administrators can open “合成事件测试” or call the `/api/v1/synthetic/*` endpoints. Fixtures live under `server/testdata/events/`, contain only invented anonymous data, and call the same Event/Receipt service as every future producer adapter. Reusing a source event ID verifies idempotency; using the same ID with a different fixture verifies conflict handling. + +In `BELL_ENV=production` those routes are not registered and return `404`; hiding the menu is not the security boundary. + ## Independent smoke check Run PostgreSQL with an empty Bell-only database, set `BELL_DATABASE_URL`, start the backend and then the web client. Keep Sense and Brain stopped. Verify `/healthz`, `/readyz`, the workbench shell, and a direct request to an unregistered framework demo route returns `404`. diff --git a/Bell/server/app/rbac/permissions.go b/Bell/server/app/rbac/permissions.go index 22ca365..42bf61f 100644 --- a/Bell/server/app/rbac/permissions.go +++ b/Bell/server/app/rbac/permissions.go @@ -6,15 +6,16 @@ import ( ) const ( - DashboardRead = "dashboard:read" - AlertsRead = "alerts:read" - AlertsHandle = "alerts:handle" - EventsRead = "events:read" - RulesRead = "rules:read" - RulesWrite = "rules:write" - UsersRead = "users:read" - UsersWrite = "users:write" - AuditRead = "audit:read" + DashboardRead = "dashboard:read" + AlertsRead = "alerts:read" + AlertsHandle = "alerts:handle" + EventsRead = "events:read" + RulesRead = "rules:read" + RulesWrite = "rules:write" + UsersRead = "users:read" + UsersWrite = "users:write" + AuditRead = "audit:read" + SyntheticWrite = "synthetic:write" ) type Set map[string]struct{} diff --git a/Bell/server/app/synthetic/http.go b/Bell/server/app/synthetic/http.go new file mode 100644 index 0000000..9cd678c --- /dev/null +++ b/Bell/server/app/synthetic/http.go @@ -0,0 +1,66 @@ +package synthetic + +import ( + "encoding/json" + "errors" + "net/http" + + "git.ilapage.cn/ila/yovision/Bell/server/app/auth" + "git.ilapage.cn/ila/yovision/Bell/server/app/event" + "git.ilapage.cn/ila/yovision/Bell/server/app/rbac" +) + +type HTTP struct { + Service Service + Auth auth.HTTP +} + +func (h HTTP) Register(mux *http.ServeMux) { + mux.Handle("GET /api/v1/synthetic/fixtures", h.Auth.Require(rbac.SyntheticWrite, http.HandlerFunc(h.fixtures))) + mux.Handle("POST /api/v1/synthetic/events", h.Auth.Require(rbac.SyntheticWrite, http.HandlerFunc(h.inject))) +} +func (h HTTP) fixtures(w http.ResponseWriter, r *http.Request) { + items, err := h.Service.Fixtures() + if err != nil { + writeJSON(w, 500, map[string]string{"error": "读取夹具失败"}) + return + } + type summary struct { + Name string `json:"name"` + Label string `json:"label"` + ExpectedFailure bool `json:"expected_failure"` + } + result := make([]summary, 0, len(items)) + for _, item := range items { + result = append(result, summary{Name: item.Name, Label: item.Label, ExpectedFailure: item.ValidationError != ""}) + } + writeJSON(w, 200, map[string]any{"items": result}) +} +func (h HTTP) inject(w http.ResponseWriter, r *http.Request) { + var input Input + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&input); err != nil { + writeJSON(w, 400, map[string]string{"error": "请求格式无效"}) + return + } + result, err := h.Service.Inject(r.Context(), input) + if err != nil { + status := 500 + if errors.Is(err, ErrFixture) || errors.Is(err, event.ErrInvalid) { + status = 400 + } else if errors.Is(err, event.ErrConflict) { + status = 409 + } + writeJSON(w, status, map[string]string{"error": err.Error()}) + return + } + status := 201 + if result.Duplicate { + status = 200 + } + writeJSON(w, status, result) +} +func writeJSON(w http.ResponseWriter, status int, value any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(value) +} diff --git a/Bell/server/app/synthetic/service.go b/Bell/server/app/synthetic/service.go new file mode 100644 index 0000000..d65c0f1 --- /dev/null +++ b/Bell/server/app/synthetic/service.go @@ -0,0 +1,33 @@ +package synthetic + +import ( + "context" + "errors" + "strings" + + "git.ilapage.cn/ila/yovision/Bell/server/app/event" + testevents "git.ilapage.cn/ila/yovision/Bell/server/testdata/events" +) + +var ErrFixture = errors.New("合成夹具验证失败") + +type Service struct{ Events event.Service } +type Input struct { + Fixture string `json:"fixture"` + SourceEventID string `json:"source_event_id,omitempty"` +} + +func (s Service) Fixtures() ([]testevents.Fixture, error) { return testevents.All() } +func (s Service) Inject(ctx context.Context, input Input) (event.Result, error) { + fixture, err := testevents.Find(input.Fixture) + if err != nil { + return event.Result{}, err + } + if fixture.ValidationError != "" { + return event.Result{}, errors.Join(ErrFixture, errors.New(fixture.ValidationError)) + } + if value := strings.TrimSpace(input.SourceEventID); value != "" { + fixture.Command.SourceEventID = value + } + return s.Events.Ingest(ctx, fixture.Command) +} diff --git a/Bell/server/app/synthetic/service_test.go b/Bell/server/app/synthetic/service_test.go new file mode 100644 index 0000000..730205e --- /dev/null +++ b/Bell/server/app/synthetic/service_test.go @@ -0,0 +1,51 @@ +package synthetic + +import ( + "context" + "errors" + "fmt" + "os" + "testing" + "time" + + "git.ilapage.cn/ila/yovision/Bell/server/app/event" + "git.ilapage.cn/ila/yovision/Bell/server/internal/platform" + "git.ilapage.cn/ila/yovision/Bell/server/migrations" +) + +func TestInvalidFixturesAreStable(t *testing.T) { + service := Service{} + for _, name := range []string{"invalid-fields", "missing-evidence"} { + if _, err := service.Inject(context.Background(), Input{Fixture: name}); !errors.Is(err, ErrFixture) { + t.Fatalf("fixture %s: got %v", name, err) + } + } +} +func TestFixtureUsesEventService(t *testing.T) { + url := os.Getenv("BELL_TEST_DATABASE_URL") + if url == "" { + t.Skip("BELL_TEST_DATABASE_URL is not set") + } + ctx := context.Background() + db, err := platform.OpenDatabase(ctx, url) + if err != nil { + t.Fatal(err) + } + defer db.Close() + if err := migrations.Apply(ctx, db); err != nil { + t.Fatal(err) + } + service := Service{Events: event.Service{DB: db}} + source := fmt.Sprintf("synthetic-%d", time.Now().UnixNano()) + first, err := service.Inject(ctx, Input{Fixture: "danger-zone", SourceEventID: source}) + if err != nil { + t.Fatal(err) + } + second, err := service.Inject(ctx, Input{Fixture: "danger-zone", SourceEventID: source}) + if err != nil { + t.Fatal(err) + } + if first.Event.ID != second.Event.ID || !second.Duplicate { + t.Fatalf("fixture bypassed idempotency: %#v %#v", first, second) + } +} diff --git a/Bell/server/cmd/bell/root.go b/Bell/server/cmd/bell/root.go index 7233569..aa51d04 100644 --- a/Bell/server/cmd/bell/root.go +++ b/Bell/server/cmd/bell/root.go @@ -15,6 +15,7 @@ import ( "git.ilapage.cn/ila/yovision/Bell/server/app/audit" "git.ilapage.cn/ila/yovision/Bell/server/app/auth" "git.ilapage.cn/ila/yovision/Bell/server/app/event" + "git.ilapage.cn/ila/yovision/Bell/server/app/synthetic" "git.ilapage.cn/ila/yovision/Bell/server/config" "git.ilapage.cn/ila/yovision/Bell/server/internal/platform" "git.ilapage.cn/ila/yovision/Bell/server/migrations" @@ -67,6 +68,9 @@ func Run(ctx context.Context, args []string) error { authHTTP := auth.HTTP{Service: authService, Store: authStore, Audit: auditStore, SecureCookie: cfg.CookieSecure} authHTTP.Register(app.Router()) event.HTTP{Service: event.Service{DB: db}, Auth: authHTTP}.Register(app.Router()) + if cfg.Environment == "development" || cfg.Environment == "test" { + synthetic.HTTP{Service: synthetic.Service{Events: event.Service{DB: db}}, Auth: authHTTP}.Register(app.Router()) + } server := &http.Server{Addr: cfg.HTTPAddress, Handler: app.Handler()} serverCtx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM) defer stop() diff --git a/Bell/server/migrations/2026081203_synthetic_permission.sql b/Bell/server/migrations/2026081203_synthetic_permission.sql new file mode 100644 index 0000000..1b706ee --- /dev/null +++ b/Bell/server/migrations/2026081203_synthetic_permission.sql @@ -0,0 +1,3 @@ +INSERT INTO bell_permissions(code,description) VALUES ('synthetic:write','注入开发测试合成事件'); +INSERT INTO bell_role_permissions(role_id,permission_code) +SELECT id,'synthetic:write' FROM bell_roles WHERE code='administrator'; diff --git a/Bell/server/testdata/events/danger-zone.json b/Bell/server/testdata/events/danger-zone.json new file mode 100644 index 0000000..123b172 --- /dev/null +++ b/Bell/server/testdata/events/danger-zone.json @@ -0,0 +1 @@ +{"name":"danger-zone","label":"危险区域进入","command":{"producer_id":"bell-synthetic","source_event_id":"fixture-danger-zone","event_type":"danger-zone-entry","occurred_at":"2026-08-12T09:00:00Z","location":"北侧围墙","severity":"high","evidence_ref":"evidence://synthetic/danger-zone","attributes":{"object":"anonymous-person","zone":"north-wall"}}} diff --git a/Bell/server/testdata/events/fixtures.go b/Bell/server/testdata/events/fixtures.go new file mode 100644 index 0000000..1da095c --- /dev/null +++ b/Bell/server/testdata/events/fixtures.go @@ -0,0 +1,57 @@ +package events + +import ( + "embed" + "encoding/json" + "fmt" + "io/fs" + "sort" + + bellEvent "git.ilapage.cn/ila/yovision/Bell/server/app/event" +) + +//go:embed *.json +var files embed.FS + +type Fixture struct { + Name string `json:"name"` + Label string `json:"label"` + ValidationError string `json:"validation_error,omitempty"` + Command bellEvent.Command `json:"command"` +} + +func All() ([]Fixture, error) { + entries, err := fs.ReadDir(files, ".") + if err != nil { + return nil, err + } + items := []Fixture{} + for _, entry := range entries { + if entry.IsDir() { + continue + } + data, err := files.ReadFile(entry.Name()) + if err != nil { + return nil, err + } + var item Fixture + if err := json.Unmarshal(data, &item); err != nil { + return nil, fmt.Errorf("decode fixture %s: %w", entry.Name(), err) + } + items = append(items, item) + } + sort.Slice(items, func(i, j int) bool { return items[i].Name < items[j].Name }) + return items, nil +} +func Find(name string) (Fixture, error) { + items, err := All() + if err != nil { + return Fixture{}, err + } + for _, item := range items { + if item.Name == name { + return item, nil + } + } + return Fixture{}, fmt.Errorf("unknown synthetic fixture %q", name) +} diff --git a/Bell/server/testdata/events/invalid-fields.json b/Bell/server/testdata/events/invalid-fields.json new file mode 100644 index 0000000..0a86a97 --- /dev/null +++ b/Bell/server/testdata/events/invalid-fields.json @@ -0,0 +1 @@ +{"name":"invalid-fields","label":"字段无效(预期失败)","validation_error":"夹具用于验证字段拒绝","command":{"producer_id":"bell-synthetic","source_event_id":"fixture-invalid","event_type":"","occurred_at":"2026-08-12T09:02:00Z","location":"测试区域","severity":"unknown","attributes":{}}} diff --git a/Bell/server/testdata/events/line-crossing.json b/Bell/server/testdata/events/line-crossing.json new file mode 100644 index 0000000..a398ec4 --- /dev/null +++ b/Bell/server/testdata/events/line-crossing.json @@ -0,0 +1 @@ +{"name":"line-crossing","label":"方向越线","command":{"producer_id":"bell-synthetic","source_event_id":"fixture-line-crossing","event_type":"line-crossing","occurred_at":"2026-08-12T09:01:00Z","location":"宿舍楼东门","severity":"critical","evidence_ref":"evidence://synthetic/line-crossing","attributes":{"direction":"outside-to-inside","object":"anonymous-person"}}} diff --git a/Bell/server/testdata/events/missing-evidence.json b/Bell/server/testdata/events/missing-evidence.json new file mode 100644 index 0000000..eb7322b --- /dev/null +++ b/Bell/server/testdata/events/missing-evidence.json @@ -0,0 +1 @@ +{"name":"missing-evidence","label":"证据缺失(预期失败)","validation_error":"此测试场景要求证据引用","command":{"producer_id":"bell-synthetic","source_event_id":"fixture-missing-evidence","event_type":"danger-zone-entry","occurred_at":"2026-08-12T09:03:00Z","location":"测试区域","severity":"high","attributes":{"requires_evidence":true}}} diff --git a/Bell/web/src/api/synthetic/index.js b/Bell/web/src/api/synthetic/index.js new file mode 100644 index 0000000..8722ac5 --- /dev/null +++ b/Bell/web/src/api/synthetic/index.js @@ -0,0 +1,3 @@ +import request from '../../bootstrap/request' +export const listFixtures = () => request.get('/api/v1/synthetic/fixtures') +export const injectSynthetic = data => request.post('/api/v1/synthetic/events', data) diff --git a/Bell/web/src/bootstrap/router.js b/Bell/web/src/bootstrap/router.js index 0deb729..ed7cde0 100644 --- a/Bell/web/src/bootstrap/router.js +++ b/Bell/web/src/bootstrap/router.js @@ -4,6 +4,8 @@ import Login from '../views/login/Login.vue' import Users from '../views/system/Users.vue' import Audit from '../views/system/Audit.vue' import Events from '../views/event/Events.vue' +import SyntheticEvent from '../views/synthetic-event/SyntheticEvent.vue' +import { listFixtures } from '../api/synthetic' import store from './store' const router = createRouter({ @@ -12,6 +14,7 @@ const router = createRouter({ { path: '/login', name: 'login', component: Login, meta: { title: '登录', public: true } }, { path: '/', name: 'dashboard', component: Dashboard, meta: { title: '工作台', permission: 'dashboard:read' } }, { path: '/events', name: 'events', component: Events, meta: { title: '事件查询', permission: 'events:read' } }, + { path: '/development/synthetic-events', name: 'synthetic-events', component: SyntheticEvent, meta: { title: '合成事件测试', permission: 'synthetic:write', developmentOnly: true } }, { path: '/system/users', name: 'users', component: Users, meta: { title: '用户与角色', permission: 'users:read' } }, { path: '/system/audit', name: 'audit', component: Audit, meta: { title: '认证审计', permission: 'audit:read' } } ] @@ -23,6 +26,7 @@ router.beforeEach(async to => { if (to.meta.public) return user && to.name === 'login' ? { name: 'dashboard' } : true if (!user) return { name: 'login', query: { redirect: to.fullPath } } if (to.meta.permission && !user.permissions?.includes(to.meta.permission)) return { name: 'dashboard' } + if (to.meta.developmentOnly) { try { await listFixtures() } catch (_) { return { name: 'dashboard' } } } return true }) diff --git a/Bell/web/src/layout/AppLayout.vue b/Bell/web/src/layout/AppLayout.vue index 18498db..9f74186 100644 --- a/Bell/web/src/layout/AppLayout.vue +++ b/Bell/web/src/layout/AppLayout.vue @@ -7,6 +7,7 @@ 事件查询 用户与角色 认证审计 + 合成事件测试
@@ -17,8 +18,9 @@ diff --git a/Bell/web/src/views/synthetic-event/SyntheticEvent.vue b/Bell/web/src/views/synthetic-event/SyntheticEvent.vue new file mode 100644 index 0000000..b37493a --- /dev/null +++ b/Bell/web/src/views/synthetic-event/SyntheticEvent.vue @@ -0,0 +1,4 @@ + +