package skills import ( "context" "errors" "testing" "git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk" ) func TestStore_UpsertGet_正常(t *testing.T) { t.Parallel() s := newTestStore(t) ctx := context.Background() if err := s.Upsert(ctx, makeSkill(t, "daily-brief")); err != nil { t.Fatalf("Upsert: %v", err) } got, err := s.Get(ctx, "daily-brief") if err != nil { t.Fatalf("Get: %v", err) } if got.Name() != "daily-brief" { t.Fatalf("Name = %q, want daily-brief", got.Name()) } if got.Description() != "desc daily-brief" { t.Fatalf("Description = %q, want desc daily-brief", got.Description()) } if got.Prompt() != "prompt daily-brief" { t.Fatalf("Prompt = %q, want prompt daily-brief", got.Prompt()) } steps := got.Steps() if len(steps) != 1 || steps[0].Name != "s1" || steps[0].Prompt != "step prompt" { t.Fatalf("Steps = %+v, want one step s1/step prompt", steps) } refs := got.Refs() if len(refs) != 1 || refs[0].Name != "r1" || refs[0].Type != "tool" || refs[0].Value != "v1" || refs[0].Optional { t.Fatalf("Refs = %+v, want r1/tool/v1/non-optional", refs) } guards := got.Guardrails() if len(guards) != 1 || guards[0].ID != "g1" || guards[0].Message != "msg" || !guards[0].Enforced { t.Fatalf("Guardrails = %+v, want g1/msg/enforced", guards) } } func TestStore_Upsert_Update_正常(t *testing.T) { t.Parallel() s := newTestStore(t) ctx := context.Background() if err := s.Upsert(ctx, makeSkill(t, "review")); err != nil { t.Fatalf("first Upsert: %v", err) } // Same name, changed description/prompt — must update in place, not duplicate. updated, err := New("review", "v2 desc", "v2 prompt", nil, nil, nil) if err != nil { t.Fatalf("New: %v", err) } if err := s.Upsert(ctx, updated); err != nil { t.Fatalf("second Upsert: %v", err) } got, err := s.Get(ctx, "review") if err != nil { t.Fatalf("Get: %v", err) } if got.Description() != "v2 desc" || got.Prompt() != "v2 prompt" { t.Fatalf("updated skill = %q/%q, want v2 desc/v2 prompt", got.Description(), got.Prompt()) } all, err := s.List(ctx) if err != nil { t.Fatalf("List: %v", err) } if len(all) != 1 { t.Fatalf("List after update has %d rows, want 1", len(all)) } } func TestStore_Get_NotFound_异常(t *testing.T) { t.Parallel() s := newTestStore(t) _, err := s.Get(context.Background(), "missing") if !errors.Is(err, ErrNotFound) { t.Fatalf("Get(missing) err = %v, want ErrNotFound", err) } } func TestStore_List_正常(t *testing.T) { t.Parallel() s := newTestStore(t) ctx := context.Background() for _, n := range []string{"b", "a", "c"} { if err := s.Upsert(ctx, makeSkill(t, n)); err != nil { t.Fatalf("Upsert(%s): %v", n, err) } } got, err := s.List(ctx) if err != nil { t.Fatalf("List: %v", err) } if len(got) != 3 { t.Fatalf("List length = %d, want 3", len(got)) } if got[0].Name() != "a" || got[1].Name() != "b" || got[2].Name() != "c" { t.Fatalf("List order = %q,%q,%q, want a,b,c", got[0].Name(), got[1].Name(), got[2].Name()) } } func TestStore_Delete_正常(t *testing.T) { t.Parallel() s := newTestStore(t) ctx := context.Background() if err := s.Upsert(ctx, makeSkill(t, "x")); err != nil { t.Fatalf("Upsert: %v", err) } if err := s.Delete(ctx, "x"); err != nil { t.Fatalf("Delete: %v", err) } if _, err := s.Get(ctx, "x"); !errors.Is(err, ErrNotFound) { t.Fatalf("Get after Delete err = %v, want ErrNotFound", err) } } func TestStore_Delete_NotFound_异常(t *testing.T) { t.Parallel() s := newTestStore(t) if err := s.Delete(context.Background(), "missing"); !errors.Is(err, ErrNotFound) { t.Fatalf("Delete(missing) err = %v, want ErrNotFound", err) } } func TestStore_Upsert_Invalid_异常(t *testing.T) { t.Parallel() s := newTestStore(t) ctx := context.Background() cases := []struct { name string sk sdk.Skill }{ {name: "nil skill", sk: nil}, {name: "blank name", sk: mockSkill{name: " "}}, } for _, tc := range cases { tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() if err := s.Upsert(ctx, tc.sk); !errors.Is(err, ErrInvalidSkill) { t.Fatalf("Upsert(%s) err = %v, want ErrInvalidSkill", tc.name, err) } }) } } // TestStore_Spec往返_正常 断言 design/09 §6.2 的新增字段真的落盘并能原样读回 —— // 尤其是 allowed-tools:读回来变成「不限」就等于把能力收缩放宽,那是安全红线, // 所以这里逐字段钉死。 func TestStore_Spec往返_正常(t *testing.T) { t.Parallel() s := newTestStore(t) ctx := context.Background() skill, err := New("guarded", "d", "p", nil, nil, nil, WithSpec(Spec{ Version: 2, WhenToUse: "何时用", Invocation: sdk.InvocationUserOnly, Triggers: []string{"简报"}, Paths: []string{"src/**/*.go"}, AllowedTools: []string{"read"}, DisallowedTools: []string{"write"}, Context: sdk.ContextFork, Agent: "researcher", Model: "fast", Effort: "high", Metadata: map[string]string{"owner": "yeiji"}, })) if err != nil { t.Fatalf("New: %v", err) } if err := s.Upsert(ctx, skill); err != nil { t.Fatalf("Upsert: %v", err) } got, err := s.Get(ctx, "guarded") if err != nil { t.Fatalf("Get: %v", err) } if got.Version() != 2 || got.WhenToUse() != "何时用" || got.Invocation() != sdk.InvocationUserOnly { t.Fatalf("往返后 Version/WhenToUse/Invocation = %d/%q/%q", got.Version(), got.WhenToUse(), got.Invocation()) } if len(got.AllowedTools()) != 1 || got.AllowedTools()[0] != "read" { t.Fatalf("AllowedTools = %v, want [read](丢了它等于放宽能力)", got.AllowedTools()) } if len(got.DisallowedTools()) != 1 || got.DisallowedTools()[0] != "write" { t.Fatalf("DisallowedTools = %v, want [write]", got.DisallowedTools()) } if got.ContextMode() != sdk.ContextFork || got.Agent() != "researcher" { t.Fatalf("Context/Agent = %q/%q, want fork/researcher", got.ContextMode(), got.Agent()) } if got.Model() != "fast" || got.Effort() != "high" { t.Fatalf("Model/Effort = %q/%q, want fast/high", got.Model(), got.Effort()) } if len(got.Triggers()) != 1 || len(got.Paths()) != 1 || got.Metadata()["owner"] != "yeiji" { t.Fatalf("Triggers/Paths/Metadata 往返失败:%v/%v/%v", got.Triggers(), got.Paths(), got.Metadata()) } } // TestStore_基础Skill往返_缺省 断言只实现基础 sdk.Skill 的技能也能落盘,读回来是 // 文档规定的缺省值(而不是零值)。 func TestStore_基础Skill往返_缺省(t *testing.T) { t.Parallel() s := newTestStore(t) ctx := context.Background() if err := s.Upsert(ctx, mockSkill{name: "plain"}); err != nil { t.Fatalf("Upsert: %v", err) } got, err := s.Get(ctx, "plain") if err != nil { t.Fatalf("Get: %v", err) } if got.Version() != sdk.DefaultSkillVersion || got.Invocation() != sdk.InvocationBoth || got.ContextMode() != sdk.ContextInline { t.Fatalf("缺省值 = %d/%q/%q, want 1/both/inline", got.Version(), got.Invocation(), got.ContextMode()) } } // TestStore_SQLInjection_安全 存入形似 SQL 的名字/描述,验证值被原样往返(参数化 // 语句)、表仍然完好、且不波及其它行。 func TestStore_SQLInjection_安全(t *testing.T) { t.Parallel() s := newTestStore(t) ctx := context.Background() name := "x'); DROP TABLE skills; --" desc := "'; SELECT * FROM skills; --" if err := s.Upsert(ctx, mockSkill{name: name}); err != nil { t.Fatalf("Upsert injection name: %v", err) } got, err := s.Get(ctx, name) if err != nil { t.Fatalf("Get injection name: %v", err) } if got.Name() != name { t.Fatalf("Name = %q, want the injection string stored verbatim", got.Name()) } // A description carrying SQL must also be stored as-is. sk, err := New("inject-desc", desc, "p", nil, nil, nil) if err != nil { t.Fatalf("New: %v", err) } if err := s.Upsert(ctx, sk); err != nil { t.Fatalf("Upsert injection desc: %v", err) } got2, err := s.Get(ctx, "inject-desc") if err != nil { t.Fatalf("Get inject-desc: %v", err) } if got2.Description() != desc { t.Fatalf("Description = %q, want verbatim injection", got2.Description()) } // The table must still be intact and usable. if err := s.Upsert(ctx, makeSkill(t, "after")); err != nil { t.Fatalf("Upsert after injection: %v", err) } all, err := s.List(ctx) if err != nil { t.Fatalf("List after injection: %v", err) } if len(all) != 3 { t.Fatalf("List after injection has %d rows, want 3", len(all)) } }