Files
knowledge-governance/internal/workflows/testweknora/upload_test.go
T

457 lines
19 KiB
Go

package testweknora
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"time"
"knowledge-governance/internal/governance"
"knowledge-governance/internal/jobs"
postgresstore "knowledge-governance/internal/platform/postgres"
"knowledge-governance/internal/platform/weknora"
)
func TestUploadHandlerStreamsVerifiedArtifactAndSchedulesFirstPoll(t *testing.T) {
root := t.TempDir()
if err := os.Mkdir(filepath.Join(root, "objects"), 0o700); err != nil {
t.Fatal(err)
}
body := []byte("safe governance document\n")
if err := os.WriteFile(filepath.Join(root, "objects", "artifact.md"), body, 0o600); err != nil {
t.Fatal(err)
}
digest := sha256.Sum256(body)
sha := hex.EncodeToString(digest[:])
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != "/api/v1/knowledge-bases/kb-1/knowledge/file" {
t.Fatalf("request = %s %s", r.Method, r.URL.Path)
}
if err := r.ParseMultipartForm(1 << 20); err != nil {
t.Fatal(err)
}
file, _, err := r.FormFile("file")
if err != nil {
t.Fatal(err)
}
defer file.Close()
uploaded, err := io.ReadAll(file)
if err != nil || string(uploaded) != string(body) {
t.Fatalf("uploaded body = %q, %v", uploaded, err)
}
if r.FormValue("metadata") != `{"content_sha256":"`+sha+`","publication_id":"publication-1"}` {
t.Fatalf("metadata = %q", r.FormValue("metadata"))
}
if r.FormValue("channel") != "governance" {
t.Fatalf("channel = %q", r.FormValue("channel"))
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"success":true,"data":{"id":"knowledge-1","knowledge_base_id":"kb-1","parse_status":"pending"}}`))
}))
defer server.Close()
client, err := weknora.NewClient(weknora.ClientConfig{BaseURL: server.URL, APIKey: "test-api-key", Timeout: time.Second})
if err != nil {
t.Fatal(err)
}
now := time.Date(2026, time.September, 4, 3, 0, 0, 0, time.UTC)
store := &uploadStoreStub{candidate: postgresstore.PublicationForUpload{
ID: "publication-1", SourceItemVersionID: "version-1", TestKBID: "kb-1",
IngestState: governance.IngestPending, ProcessingState: governance.ProcessingPending,
ContentObjectID: "content-1", SHA256: sha, SizeBytes: int64(len(body)),
ValidatedFormat: "md", LocalSpoolRef: "objects/artifact.md", Title: "Safe title",
}}
handler, err := NewUploadHandler(UploadHandlerConfig{
Store: store, Client: client, SpoolRoot: root, MaxUploadBytes: 50 * 1024 * 1024,
FirstPollDelay: 10 * time.Second, Now: func() time.Time { return now },
})
if err != nil {
t.Fatal(err)
}
job := jobs.Job{ID: "job-1", Type: "test.upload", Payload: []byte(`{"publication_id":"publication-1"}`)}
if err := handler.Handle(context.Background(), job); err != nil {
t.Fatalf("Handle() error = %v", err)
}
if !store.markedUploading || store.recordedKnowledgeID != "knowledge-1" || !store.nextPollAt.Equal(now.Add(10*time.Second)) {
t.Fatalf("store = %#v", store)
}
}
func TestUploadHandlerRejectsJobSnapshotWithMismatchedTarget(t *testing.T) {
root := t.TempDir()
now := time.Date(2026, time.September, 4, 3, 0, 0, 0, time.UTC)
store := &uploadStoreStub{candidate: postgresstore.PublicationForUpload{
ID: "publication-1", SourceItemVersionID: "version-1", TestKBID: "kb-1", TargetScope: governance.VisibilityInternal,
IngestState: governance.IngestPending, ProcessingState: governance.ProcessingPending,
}}
client, err := weknora.NewClient(weknora.ClientConfig{BaseURL: "http://127.0.0.1:1", APIKey: "***", Timeout: time.Second})
if err != nil {
t.Fatal(err)
}
handler, err := NewUploadHandler(UploadHandlerConfig{Store: store, Client: client, SpoolRoot: root, MaxUploadBytes: 1 << 20, FirstPollDelay: time.Second, Now: func() time.Time { return now }})
if err != nil {
t.Fatal(err)
}
job := jobs.Job{ID: "job-1", Type: "test.upload", Payload: []byte(`{"publication_id":"publication-1","target_scope":"external","target_kb_id":"kb-1"}`)}
if err := handler.Handle(context.Background(), job); !errors.Is(err, ErrUploadStateConflict) {
t.Fatalf("scope mismatch error = %v", err)
}
job = jobs.Job{ID: "job-2", Type: "test.upload", Payload: []byte(`{"publication_id":"publication-1","target_scope":"internal","target_kb_id":"kb-other"}`)}
if err := handler.Handle(context.Background(), job); !errors.Is(err, ErrUploadStateConflict) {
t.Fatalf("kb mismatch error = %v", err)
}
if store.markedUploading {
t.Fatal("mismatched job must not mark uploading")
}
}
func TestUploadHandlerRecoversUniqueRemoteKnowledgeAfterResponseLoss(t *testing.T) {
root := t.TempDir()
if err := os.Mkdir(filepath.Join(root, "objects"), 0o700); err != nil {
t.Fatal(err)
}
body := []byte("safe ambiguous upload\n")
if err := os.WriteFile(filepath.Join(root, "objects", "artifact.md"), body, 0o600); err != nil {
t.Fatal(err)
}
digest := sha256.Sum256(body)
sha := hex.EncodeToString(digest[:])
var uploads atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodPost:
uploads.Add(1)
if err := r.ParseMultipartForm(1 << 20); err != nil {
t.Fatal(err)
}
file, _, err := r.FormFile("file")
if err != nil {
t.Fatal(err)
}
_, _ = io.Copy(io.Discard, file)
_ = file.Close()
connection, _, err := w.(http.Hijacker).Hijack()
if err != nil {
t.Fatal(err)
}
_ = connection.Close()
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/knowledge-bases/kb-1/knowledge":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"success":true,"data":[{"id":"knowledge-recovered","knowledge_base_id":"kb-1","metadata":{"publication_id":"publication-1","content_sha256":"` + sha + `"}}],"total":1,"page":1,"page_size":100}`))
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/knowledge/knowledge-recovered":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"success":true,"data":{"id":"knowledge-recovered","knowledge_base_id":"kb-1","metadata":{"publication_id":"publication-1","content_sha256":"` + sha + `"}}}`))
default:
http.NotFound(w, r)
}
}))
defer server.Close()
client, err := weknora.NewClient(weknora.ClientConfig{BaseURL: server.URL, APIKey: "test-api-key", Timeout: time.Second})
if err != nil {
t.Fatal(err)
}
now := time.Date(2026, time.September, 4, 3, 10, 0, 0, time.UTC)
store := &uploadStoreStub{candidate: postgresstore.PublicationForUpload{
ID: "publication-1", TestKBID: "kb-1", IngestState: governance.IngestPending,
ProcessingState: governance.ProcessingPending, SHA256: sha, SizeBytes: int64(len(body)),
ValidatedFormat: "md", LocalSpoolRef: "objects/artifact.md", Title: "Safe title",
}}
handler, err := NewUploadHandler(UploadHandlerConfig{
Store: store, Client: client, SpoolRoot: root, MaxUploadBytes: 50 * 1024 * 1024,
FirstPollDelay: time.Second, Now: func() time.Time { return now },
})
if err != nil {
t.Fatal(err)
}
if err := handler.Handle(context.Background(), jobs.Job{Payload: []byte(`{"publication_id":"publication-1"}`)}); err != nil {
t.Fatalf("Handle() error = %v", err)
}
if uploads.Load() != 1 || store.recordedKnowledgeID != "knowledge-recovered" {
t.Fatalf("uploads=%d knowledge=%q", uploads.Load(), store.recordedKnowledgeID)
}
}
func TestUploadHandlerRecoversUploadingStateBeforeAnotherPost(t *testing.T) {
root := t.TempDir()
if err := os.Mkdir(filepath.Join(root, "objects"), 0o700); err != nil {
t.Fatal(err)
}
body := []byte("safe retry recovery\n")
if err := os.WriteFile(filepath.Join(root, "objects", "artifact.md"), body, 0o600); err != nil {
t.Fatal(err)
}
digest := sha256.Sum256(body)
sha := hex.EncodeToString(digest[:])
var uploads atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodPost:
uploads.Add(1)
http.Error(w, "must not upload", http.StatusInternalServerError)
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/knowledge-bases/kb-1/knowledge":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"success":true,"data":[{"id":"knowledge-existing","knowledge_base_id":"kb-1","metadata":{"publication_id":"publication-1","content_sha256":"` + sha + `"}}],"total":1,"page":1,"page_size":100}`))
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/knowledge/knowledge-existing":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"success":true,"data":{"id":"knowledge-existing","knowledge_base_id":"kb-1","metadata":{"publication_id":"publication-1","content_sha256":"` + sha + `"}}}`))
default:
http.NotFound(w, r)
}
}))
defer server.Close()
client, err := weknora.NewClient(weknora.ClientConfig{BaseURL: server.URL, APIKey: "test-api-key", Timeout: time.Second})
if err != nil {
t.Fatal(err)
}
now := time.Date(2026, time.September, 4, 3, 20, 0, 0, time.UTC)
store := &uploadStoreStub{candidate: postgresstore.PublicationForUpload{
ID: "publication-1", TestKBID: "kb-1", IngestState: governance.IngestUploading,
ProcessingState: governance.ProcessingPending, SHA256: sha, SizeBytes: int64(len(body)),
ValidatedFormat: "md", LocalSpoolRef: "objects/artifact.md", Title: "Safe title",
}}
handler, err := NewUploadHandler(UploadHandlerConfig{
Store: store, Client: client, SpoolRoot: root, MaxUploadBytes: 50 * 1024 * 1024,
FirstPollDelay: time.Second, Now: func() time.Time { return now },
})
if err != nil {
t.Fatal(err)
}
if err := handler.Handle(context.Background(), jobs.Job{Payload: []byte(`{"publication_id":"publication-1"}`)}); err != nil {
t.Fatalf("Handle() error = %v", err)
}
if uploads.Load() != 0 || store.recordedKnowledgeID != "knowledge-existing" {
t.Fatalf("uploads=%d knowledge=%q", uploads.Load(), store.recordedKnowledgeID)
}
}
func TestUploadHandlerFailsClosedWhenAmbiguousRecoveryHasMultipleMatches(t *testing.T) {
root := t.TempDir()
if err := os.Mkdir(filepath.Join(root, "objects"), 0o700); err != nil {
t.Fatal(err)
}
body := []byte("safe ambiguous duplicate\n")
if err := os.WriteFile(filepath.Join(root, "objects", "artifact.md"), body, 0o600); err != nil {
t.Fatal(err)
}
digest := sha256.Sum256(body)
sha := hex.EncodeToString(digest[:])
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodPost:
_, _ = io.Copy(io.Discard, r.Body)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusConflict)
_, _ = w.Write([]byte(`{"success":false,"data":{}}`))
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/knowledge-bases/kb-1/knowledge":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"success":true,"data":[` +
`{"id":"knowledge-a","knowledge_base_id":"kb-1","metadata":{"publication_id":"publication-1","content_sha256":"` + sha + `"}},` +
`{"id":"knowledge-b","knowledge_base_id":"kb-1","metadata":{"publication_id":"publication-1","content_sha256":"` + sha + `"}}` +
`],"total":2,"page":1,"page_size":100}`))
default:
http.NotFound(w, r)
}
}))
defer server.Close()
client, err := weknora.NewClient(weknora.ClientConfig{BaseURL: server.URL, APIKey: "test-api-key", Timeout: time.Second})
if err != nil {
t.Fatal(err)
}
now := time.Date(2026, time.September, 4, 3, 30, 0, 0, time.UTC)
store := &uploadStoreStub{candidate: postgresstore.PublicationForUpload{
ID: "publication-1", TestKBID: "kb-1", IngestState: governance.IngestPending,
ProcessingState: governance.ProcessingPending, SHA256: sha, SizeBytes: int64(len(body)),
ValidatedFormat: "md", LocalSpoolRef: "objects/artifact.md", Title: "Safe title",
}}
handler, err := NewUploadHandler(UploadHandlerConfig{
Store: store, Client: client, SpoolRoot: root, MaxUploadBytes: 50 * 1024 * 1024,
FirstPollDelay: time.Second, Now: func() time.Time { return now },
})
if err != nil {
t.Fatal(err)
}
if err := handler.Handle(context.Background(), jobs.Job{Payload: []byte(`{"publication_id":"publication-1"}`)}); err != nil {
t.Fatalf("Handle() error = %v", err)
}
if store.uploadFailureCode != "upload_identity_ambiguous" || store.recordedKnowledgeID != "" {
t.Fatalf("failure=%q knowledge=%q", store.uploadFailureCode, store.recordedKnowledgeID)
}
}
func TestUploadHandlerRecordsInvalidArtifactForManualRetry(t *testing.T) {
root := t.TempDir()
store := &uploadStoreStub{candidate: postgresstore.PublicationForUpload{
ID: "publication-1", TestKBID: "kb-1", IngestState: governance.IngestPending,
ProcessingState: governance.ProcessingPending, SHA256: strings.Repeat("a", 64), SizeBytes: 128,
ValidatedFormat: "md", LocalSpoolRef: "missing/artifact.md", Title: "Safe title",
}}
handler, err := NewUploadHandler(UploadHandlerConfig{
Store: store, Client: uploadClientStub{}, SpoolRoot: root, MaxUploadBytes: 1024,
FirstPollDelay: time.Second,
})
if err != nil {
t.Fatal(err)
}
if err := handler.Handle(context.Background(), jobs.Job{Payload: []byte(`{"publication_id":"publication-1"}`)}); err != nil {
t.Fatalf("Handle() error = %v, want terminal failure recorded", err)
}
if store.uploadFailureCode != "upload_artifact_invalid" || store.markedUploading {
t.Fatalf("failure=%q markedUploading=%v", store.uploadFailureCode, store.markedUploading)
}
}
type uploadStoreStub struct {
candidate postgresstore.PublicationForUpload
markedUploading bool
recordedKnowledgeID string
nextPollAt time.Time
uploadFailureCode string
}
func (store *uploadStoreStub) LoadPublicationForUpload(context.Context, string) (postgresstore.PublicationForUpload, error) {
return store.candidate, nil
}
func (store *uploadStoreStub) MarkUploading(context.Context, string, time.Time) error {
store.markedUploading = true
return nil
}
func (store *uploadStoreStub) RecordUploadAndSchedulePoll(_ context.Context, _ string, knowledgeID string, _ time.Time, nextAt time.Time) (int, error) {
store.recordedKnowledgeID = knowledgeID
store.nextPollAt = nextAt
return 1, nil
}
func (store *uploadStoreStub) RecordUploadFailure(_ context.Context, _ string, code string, _ time.Time) error {
store.uploadFailureCode = code
return nil
}
type uploadClientStub struct{}
func (uploadClientStub) UploadFile(context.Context, weknora.UploadRequest) (weknora.Knowledge, error) {
return weknora.Knowledge{}, errors.New("unexpected upload")
}
func (uploadClientStub) ListKnowledge(context.Context, string, int, int) ([]weknora.Knowledge, int64, error) {
return nil, 0, errors.New("unexpected list")
}
func (uploadClientStub) GetKnowledge(context.Context, string) (weknora.Knowledge, error) {
return weknora.Knowledge{}, errors.New("unexpected get")
}
func TestUploadHandlerAdoptsSameSHAItemOnDuplicateConflict(t *testing.T) {
root := t.TempDir()
if err := os.Mkdir(filepath.Join(root, "objects"), 0o700); err != nil {
t.Fatal(err)
}
body := []byte("duplicated across two folders\n")
if err := os.WriteFile(filepath.Join(root, "objects", "artifact.md"), body, 0o600); err != nil {
t.Fatal(err)
}
digest := sha256.Sum256(body)
sha := hex.EncodeToString(digest[:])
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodPost:
_, _ = io.Copy(io.Discard, r.Body)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusConflict)
_, _ = w.Write([]byte(`{"success":false,"code":"duplicate_file","message":"file exists","data":{"id":"knowledge-sibling","knowledge_base_id":"kb-1","file_hash":"` + sha + `"}}`))
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/knowledge/knowledge-sibling":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"success":true,"data":{"id":"knowledge-sibling","knowledge_base_id":"kb-1","file_hash":"` + sha + `","metadata":{"publication_id":"publication-other","content_sha256":"` + sha + `"}}}`))
default:
http.NotFound(w, r)
}
}))
defer server.Close()
client, err := weknora.NewClient(weknora.ClientConfig{BaseURL: server.URL, APIKey: "test-key", Timeout: time.Second})
if err != nil {
t.Fatal(err)
}
store := &uploadStoreStub{candidate: postgresstore.PublicationForUpload{
ID: "publication-1", TestKBID: "kb-1", IngestState: governance.IngestPending,
ProcessingState: governance.ProcessingPending, SHA256: sha, SizeBytes: int64(len(body)),
ValidatedFormat: "md", LocalSpoolRef: "objects/artifact.md", Title: "Safe title",
}}
handler, err := NewUploadHandler(UploadHandlerConfig{
Store: store, Client: client, SpoolRoot: root, MaxUploadBytes: 50 * 1024 * 1024,
FirstPollDelay: time.Second, Now: func() time.Time { return time.Now() },
})
if err != nil {
t.Fatal(err)
}
if err := handler.Handle(context.Background(), jobs.Job{Payload: []byte(`{"publication_id":"publication-1"}`)}); err != nil {
t.Fatalf("Handle() error = %v, want duplicate adoption", err)
}
if store.recordedKnowledgeID != "knowledge-sibling" {
t.Fatalf("knowledge=%q", store.recordedKnowledgeID)
}
}
func TestUploadHandlerAdoptsUniqueSameSHAItemWhenOwnRecoveryFails(t *testing.T) {
root := t.TempDir()
if err := os.Mkdir(filepath.Join(root, "objects"), 0o700); err != nil {
t.Fatal(err)
}
body := []byte("uploading-state duplicate\n")
if err := os.WriteFile(filepath.Join(root, "objects", "artifact.md"), body, 0o600); err != nil {
t.Fatal(err)
}
digest := sha256.Sum256(body)
sha := hex.EncodeToString(digest[:])
var uploads atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodPost:
uploads.Add(1)
http.Error(w, "must not upload while recovering", http.StatusInternalServerError)
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/knowledge-bases/kb-1/knowledge":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"success":true,"data":[{"id":"knowledge-sibling","knowledge_base_id":"kb-1","metadata":{"publication_id":"publication-sibling","content_sha256":"` + sha + `"}}],"total":1,"page":1,"page_size":100}`))
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/knowledge/knowledge-sibling":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"success":true,"data":{"id":"knowledge-sibling","knowledge_base_id":"kb-1","metadata":{"publication_id":"publication-sibling","content_sha256":"` + sha + `"}}}`))
default:
http.NotFound(w, r)
}
}))
defer server.Close()
client, err := weknora.NewClient(weknora.ClientConfig{BaseURL: server.URL, APIKey: "test-key", Timeout: time.Second})
if err != nil {
t.Fatal(err)
}
store := &uploadStoreStub{candidate: postgresstore.PublicationForUpload{
ID: "publication-1", TestKBID: "kb-1", IngestState: governance.IngestUploading,
ProcessingState: governance.ProcessingPending, SHA256: sha, SizeBytes: int64(len(body)),
ValidatedFormat: "md", LocalSpoolRef: "objects/artifact.md", Title: "Safe title",
}}
handler, err := NewUploadHandler(UploadHandlerConfig{
Store: store, Client: client, SpoolRoot: root, MaxUploadBytes: 50 * 1024 * 1024,
FirstPollDelay: time.Second, Now: func() time.Time { return time.Now() },
})
if err != nil {
t.Fatal(err)
}
if err := handler.Handle(context.Background(), jobs.Job{Payload: []byte(`{"publication_id":"publication-1"}`)}); err != nil {
t.Fatalf("Handle() error = %v, want sibling adoption", err)
}
if uploads.Load() != 0 || store.recordedKnowledgeID != "knowledge-sibling" {
t.Fatalf("uploads=%d knowledge=%q", uploads.Load(), store.recordedKnowledgeID)
}
}