Artifact and media metadata were previously reconstructed from job result refs and JSON sidecar files. This change regularizes metadata into SQLite, keeps filesystem blobs in place, and preserves backward compatibility via lazy fallback and backfill from existing job refs and JSON metadata. Constraint: Blob storage remains on the local filesystem in this phase Rejected: Migrate blobs into SQLite | larger scope and worse operational profile for current media sizes Rejected: Hard cutover without fallback | unsafe for historical data already on the test server Confidence: medium Scope-risk: moderate Directive: Treat SQLite as the metadata source of truth; JSON sidecars are compatibility fallback only Tested: go test ./...; deployed to test server 101.42.99.35; verified /v1/artifacts, /v1/artifacts/:id, signed media URL 200, unsigned content 401 Not-tested: Full historical backfill sweep over all existing artifact rows under production-sized data volume
301 lines
9.4 KiB
Go
301 lines
9.4 KiB
Go
package server
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestArtifactUploadCreatesReadableArtifactForSourceArtifactID(t *testing.T) {
|
|
cfg := Config{
|
|
SQLitePath: filepath.Join(t.TempDir(), "popiart.db"),
|
|
SkillhubDir: makeEmptySkillhub(t),
|
|
SessionSecret: "test-secret",
|
|
}
|
|
server, err := NewWithConfig(cfg)
|
|
if err != nil {
|
|
t.Fatalf("NewWithConfig: %v", err)
|
|
}
|
|
srv := httptest.NewServer(server.Handler())
|
|
defer srv.Close()
|
|
server.cfg.PublicBaseURL = srv.URL
|
|
|
|
sessionToken, _, ok, err := server.store.createSession("sk-upload-user")
|
|
if err != nil {
|
|
t.Fatalf("createSession: %v", err)
|
|
}
|
|
if !ok {
|
|
t.Fatal("expected session creation to succeed")
|
|
}
|
|
current, exists, err := server.store.session(sessionToken)
|
|
if err != nil {
|
|
t.Fatalf("load session: %v", err)
|
|
}
|
|
if !exists {
|
|
t.Fatal("expected stored session")
|
|
}
|
|
|
|
imageBytes := tinyPNG(t)
|
|
var body bytes.Buffer
|
|
writer := multipart.NewWriter(&body)
|
|
if err := writer.WriteField("role", "source"); err != nil {
|
|
t.Fatalf("write role field: %v", err)
|
|
}
|
|
if err := writer.WriteField("metadata_json", `{"origin":"chat-upload"}`); err != nil {
|
|
t.Fatalf("write metadata field: %v", err)
|
|
}
|
|
part, err := writer.CreateFormFile("file", "chat-upload.png")
|
|
if err != nil {
|
|
t.Fatalf("create form file: %v", err)
|
|
}
|
|
if _, err := part.Write(imageBytes); err != nil {
|
|
t.Fatalf("write form file: %v", err)
|
|
}
|
|
if err := writer.Close(); err != nil {
|
|
t.Fatalf("close multipart writer: %v", err)
|
|
}
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/artifacts/upload", &body)
|
|
req.Header.Set("Authorization", "Bearer "+sessionToken)
|
|
req.Header.Set("Content-Type", writer.FormDataContentType())
|
|
rec := httptest.NewRecorder()
|
|
server.Handler().ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusCreated {
|
|
t.Fatalf("expected status 201, got %d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
|
|
var envelope struct {
|
|
OK bool `json:"ok"`
|
|
Data artifact `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &envelope); err != nil {
|
|
t.Fatalf("decode upload response: %v body=%s", err, rec.Body.String())
|
|
}
|
|
if !envelope.OK {
|
|
t.Fatalf("expected ok upload response, got %s", rec.Body.String())
|
|
}
|
|
if envelope.Data.ID == "" {
|
|
t.Fatalf("expected artifact id, got %#v", envelope.Data)
|
|
}
|
|
if envelope.Data.MediaID == "" {
|
|
t.Fatalf("expected media id, got %#v", envelope.Data)
|
|
}
|
|
if envelope.Data.ContentType != "image/png" {
|
|
t.Fatalf("expected image/png, got %q", envelope.Data.ContentType)
|
|
}
|
|
if envelope.Data.URL == "" {
|
|
t.Fatalf("expected stable artifact url, got %#v", envelope.Data)
|
|
}
|
|
if envelope.Data.StorageStatus != "ready" {
|
|
t.Fatalf("expected storage status ready, got %#v", envelope.Data.StorageStatus)
|
|
}
|
|
if envelope.Data.SourceSkillID != "popiskill-artifact-upload-local-v1" {
|
|
t.Fatalf("expected source skill id, got %#v", envelope.Data.SourceSkillID)
|
|
}
|
|
if envelope.Data.SourceRouteKey != "artifact.upload" {
|
|
t.Fatalf("expected source route key, got %#v", envelope.Data.SourceRouteKey)
|
|
}
|
|
if envelope.Data.SourceInput["role"] != "source" {
|
|
t.Fatalf("expected source input role, got %#v", envelope.Data.SourceInput)
|
|
}
|
|
|
|
contentResp, err := http.Get(envelope.Data.URL)
|
|
if err != nil {
|
|
t.Fatalf("GET media content: %v", err)
|
|
}
|
|
defer contentResp.Body.Close()
|
|
if contentResp.StatusCode != http.StatusOK {
|
|
t.Fatalf("expected media content 200, got %d", contentResp.StatusCode)
|
|
}
|
|
streamed, err := io.ReadAll(contentResp.Body)
|
|
if err != nil {
|
|
t.Fatalf("read media content: %v", err)
|
|
}
|
|
if !bytes.Equal(streamed, imageBytes) {
|
|
t.Fatal("expected stable media url to serve uploaded bytes")
|
|
}
|
|
|
|
editRef, err := server.resolveImageToImageReference(context.Background(), &job{
|
|
UserID: current.User.ID,
|
|
SessionID: current.Token,
|
|
UpstreamKey: current.UpstreamKey,
|
|
SkillID: "popiskill-image-img2img-basic-v1",
|
|
}, map[string]any{
|
|
"source_artifact_id": envelope.Data.ID,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("resolveImageToImageReference: %v", err)
|
|
}
|
|
if editRef.ContentType != "image/png" {
|
|
t.Fatalf("expected resolved content type image/png, got %q", editRef.ContentType)
|
|
}
|
|
if !bytes.Equal(editRef.Content, imageBytes) {
|
|
t.Fatal("expected resolved reference content to match uploaded bytes")
|
|
}
|
|
|
|
unsignedResp, err := http.Get(strings.Split(envelope.Data.URL, "?")[0])
|
|
if err != nil {
|
|
t.Fatalf("GET unsigned artifact media content: %v", err)
|
|
}
|
|
defer unsignedResp.Body.Close()
|
|
if unsignedResp.StatusCode != http.StatusUnauthorized {
|
|
t.Fatalf("expected unsigned artifact media content 401, got %d", unsignedResp.StatusCode)
|
|
}
|
|
|
|
repo, ok := server.store.artifacts.(*sqliteRepository)
|
|
if !ok {
|
|
t.Fatalf("expected sqliteRepository, got %T", server.store.artifacts)
|
|
}
|
|
if _, err := repo.db.Exec(`UPDATE jobs SET result_refs_json = NULL WHERE job_id = ?`, envelope.Data.JobID); err != nil {
|
|
t.Fatalf("clear job result refs: %v", err)
|
|
}
|
|
|
|
getReq := httptest.NewRequest(http.MethodGet, "/v1/artifacts/"+envelope.Data.ID, nil)
|
|
getReq.Header.Set("Authorization", "Bearer "+sessionToken)
|
|
getRec := httptest.NewRecorder()
|
|
server.Handler().ServeHTTP(getRec, getReq)
|
|
if getRec.Code != http.StatusOK {
|
|
t.Fatalf("expected sqlite-backed artifact get 200, got %d body=%s", getRec.Code, getRec.Body.String())
|
|
}
|
|
|
|
listReq := httptest.NewRequest(http.MethodGet, "/v1/artifacts?job_id="+envelope.Data.JobID, nil)
|
|
listReq.Header.Set("Authorization", "Bearer "+sessionToken)
|
|
listRec := httptest.NewRecorder()
|
|
server.Handler().ServeHTTP(listRec, listReq)
|
|
if listRec.Code != http.StatusOK {
|
|
t.Fatalf("expected sqlite-backed artifacts list 200, got %d body=%s", listRec.Code, listRec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestResolveImageToImageReferenceAcceptsCanonicalImageURL(t *testing.T) {
|
|
cfg := Config{
|
|
SQLitePath: filepath.Join(t.TempDir(), "popiart.db"),
|
|
SkillhubDir: makeEmptySkillhub(t),
|
|
SessionSecret: "test-secret",
|
|
}
|
|
server, err := NewWithConfig(cfg)
|
|
if err != nil {
|
|
t.Fatalf("NewWithConfig: %v", err)
|
|
}
|
|
|
|
imageBytes := tinyPNG(t)
|
|
refSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "image/png")
|
|
_, _ = w.Write(imageBytes)
|
|
}))
|
|
defer refSrv.Close()
|
|
|
|
editRef, err := server.resolveImageToImageReference(context.Background(), &job{
|
|
UserID: "user_test",
|
|
UpstreamKey: "sk-upstream",
|
|
SkillID: "popiskill-image-img2img-basic-v1",
|
|
}, map[string]any{
|
|
"image": refSrv.URL + "/reference.png",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("resolveImageToImageReference: %v", err)
|
|
}
|
|
if editRef.URL != refSrv.URL+"/reference.png" {
|
|
t.Fatalf("expected resolved URL to match input image, got %q", editRef.URL)
|
|
}
|
|
if editRef.ContentType != "image/png" {
|
|
t.Fatalf("expected image/png, got %q", editRef.ContentType)
|
|
}
|
|
if !bytes.Equal(editRef.Content, imageBytes) {
|
|
t.Fatal("expected resolved canonical image content to match downloaded bytes")
|
|
}
|
|
}
|
|
|
|
func TestResolveImageToImageReferenceAcceptsSignedSameOriginMediaURL(t *testing.T) {
|
|
cfg := Config{
|
|
SQLitePath: filepath.Join(t.TempDir(), "popiart.db"),
|
|
SkillhubDir: makeEmptySkillhub(t),
|
|
SessionSecret: "test-secret",
|
|
}
|
|
server, err := NewWithConfig(cfg)
|
|
if err != nil {
|
|
t.Fatalf("NewWithConfig: %v", err)
|
|
}
|
|
srv := httptest.NewServer(server.Handler())
|
|
defer srv.Close()
|
|
server.cfg.PublicBaseURL = srv.URL
|
|
|
|
sessionToken, _, ok, err := server.store.createSession("sk-same-origin-user")
|
|
if err != nil {
|
|
t.Fatalf("createSession: %v", err)
|
|
}
|
|
if !ok {
|
|
t.Fatal("expected session creation to succeed")
|
|
}
|
|
current, exists, err := server.store.session(sessionToken)
|
|
if err != nil {
|
|
t.Fatalf("load session: %v", err)
|
|
}
|
|
if !exists {
|
|
t.Fatal("expected stored session")
|
|
}
|
|
|
|
imageBytes := tinyPNG(t)
|
|
var body bytes.Buffer
|
|
writer := multipart.NewWriter(&body)
|
|
part, err := writer.CreateFormFile("file", "same-origin.png")
|
|
if err != nil {
|
|
t.Fatalf("create form file: %v", err)
|
|
}
|
|
if _, err := part.Write(imageBytes); err != nil {
|
|
t.Fatalf("write form file: %v", err)
|
|
}
|
|
if err := writer.Close(); err != nil {
|
|
t.Fatalf("close multipart writer: %v", err)
|
|
}
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/media/upload", &body)
|
|
req.Header.Set("Authorization", "Bearer "+sessionToken)
|
|
req.Header.Set("Content-Type", writer.FormDataContentType())
|
|
rec := httptest.NewRecorder()
|
|
server.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusCreated {
|
|
t.Fatalf("expected media upload 201, got %d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
|
|
var envelope struct {
|
|
OK bool `json:"ok"`
|
|
Data media `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &envelope); err != nil {
|
|
t.Fatalf("decode media upload response: %v body=%s", err, rec.Body.String())
|
|
}
|
|
if !envelope.OK || envelope.Data.URL == "" {
|
|
t.Fatalf("expected signed media url, got %s", rec.Body.String())
|
|
}
|
|
|
|
editRef, err := server.resolveImageToImageReference(context.Background(), &job{
|
|
UserID: current.User.ID,
|
|
SessionID: current.Token,
|
|
UpstreamKey: current.UpstreamKey,
|
|
SkillID: "popiskill-image-img2img-basic-v1",
|
|
}, map[string]any{
|
|
"image": envelope.Data.URL,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("resolveImageToImageReference same-origin: %v", err)
|
|
}
|
|
if editRef.URL != envelope.Data.URL {
|
|
t.Fatalf("expected resolved URL to round-trip, got %q", editRef.URL)
|
|
}
|
|
if editRef.ContentType != "image/png" {
|
|
t.Fatalf("expected image/png, got %q", editRef.ContentType)
|
|
}
|
|
if !bytes.Equal(editRef.Content, imageBytes) {
|
|
t.Fatal("expected same-origin signed media bytes to match uploaded file")
|
|
}
|
|
}
|