Files
Enterprise-Knowledge-Base/internal/application/service/knowledge_move_gate_test.go
T
ochan.kwon db7baad84c fix: reject cross-store reuse_vectors knowledge move
Moving knowledge with mode=reuse_vectors copies vector index entries
directly between knowledge bases, which only works when both KBs are
bound to the same vector store (CopyIndices is routed through the source
store). Nothing rejected a cross-store reuse_vectors move, so it would
copy target-KB rows into the source store and then delete the source
indices — corrupting vector data. The move endpoint validated only the
embedding model, and ListMoveTargets surfaces same-model KBs regardless
of their store binding, so a cross-store target is reachable.

Reject reuse_vectors moves whose source and target KBs are bound to
different vector stores:

- MoveKnowledge handler: synchronous 400 before enqueueing, with a
  message pointing the caller at reparse mode.
- moveOneKnowledge: re-check before marking the knowledge as processing,
  so a rejected move leaves it untouched rather than stranded.
- moveKnowledgeReuseVectors: a top-of-function guard as defense in depth
  for any directly enqueued task, satisfying the pre-existing contract
  noted in the comment ("callers must reject cross-store moves").

reparse mode is unaffected: it re-indexes into the target store from
scratch and is the supported way to move across stores.
2026-06-05 15:00:41 +08:00

63 lines
2.3 KiB
Go

package service
import (
"context"
"testing"
"github.com/Tencent/WeKnora/internal/types"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestMoveKnowledgeReuseVectors_CrossStoreRejected verifies the defense-in-depth
// guard: a reuse_vectors move whose source and target KBs are bound to different
// vector stores must be rejected before any index copy runs. The guard fires at
// the top of moveKnowledgeReuseVectors, so an empty knowledgeService (no repos)
// is sufficient — the function returns before touching any dependency.
func TestMoveKnowledgeReuseVectors_CrossStoreRejected(t *testing.T) {
s := &knowledgeService{}
ctx := context.WithValue(context.Background(), types.TenantIDContextKey, uint64(1))
storeA, storeB := "store-a", "store-b"
tests := []struct {
name string
srcStore *string
dstStore *string
}{
{"both bound, different stores", &storeA, &storeB},
{"source bound, target env-store", &storeA, nil},
{"source env-store, target bound", nil, &storeB},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
src := &types.KnowledgeBase{ID: "kb-src", VectorStoreID: tt.srcStore}
dst := &types.KnowledgeBase{ID: "kb-dst", VectorStoreID: tt.dstStore}
kn := &types.Knowledge{ID: "k1", EmbeddingModelID: "m1"}
err := s.moveKnowledgeReuseVectors(ctx, kn, src, dst)
require.Error(t, err)
assert.Contains(t, err.Error(), "different vector stores")
})
}
}
// TestSharesStoreWith_GuardSemantics pins the same/cross-store classification the
// move gate relies on, including the env-store (nil) normalization, so a change
// to SharesStoreWith that would silently re-open the cross-store reuse_vectors
// hole fails here.
func TestSharesStoreWith_GuardSemantics(t *testing.T) {
storeA, storeB, empty := "store-a", "store-b", ""
same := func(a, b *string) bool {
return (&types.KnowledgeBase{VectorStoreID: a}).
SharesStoreWith(&types.KnowledgeBase{VectorStoreID: b})
}
assert.True(t, same(nil, nil), "both env-store → same")
assert.True(t, same(&empty, nil), "empty string normalizes to env-store")
assert.True(t, same(&storeA, &storeA), "same UUID → same")
assert.False(t, same(&storeA, &storeB), "different UUID → cross-store")
assert.False(t, same(&storeA, nil), "bound vs env-store → cross-store")
}