Add asset metadata, sharing, and MinIO-backed signed links.
CI / test (pull_request) Successful in 4s
CI / test (pull_request) Successful in 4s
This introduces deduplicated per-user image/3D asset records linked into feature properties, adds visibility-controlled download routing, and wires local S3-compatible storage with automatic bucket bootstrap in Docker Compose. Made-with: Cursor
This commit is contained in:
@@ -2,10 +2,12 @@ package httpapi_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
@@ -23,10 +25,21 @@ func newTestServer(adminPublicKey string) *httptest.Server {
|
||||
SessionTTL: 24 * time.Hour,
|
||||
}, adminPublicKey)
|
||||
svc.BootstrapAdmin(adminPublicKey)
|
||||
svc.ConfigureAssetStorage(fakeSigner{})
|
||||
api := httpapi.NewAPI(svc)
|
||||
return httptest.NewServer(api.Routes())
|
||||
}
|
||||
|
||||
type fakeSigner struct{}
|
||||
|
||||
func (fakeSigner) SignedPutObjectURL(_ context.Context, objectKey string, _ time.Duration, _ string) (string, error) {
|
||||
return "http://files.local/upload/" + objectKey, nil
|
||||
}
|
||||
|
||||
func (fakeSigner) SignedGetObjectURL(_ context.Context, objectKey string, _ time.Duration) (string, error) {
|
||||
return "http://files.local/download/" + objectKey, nil
|
||||
}
|
||||
|
||||
func mustJSON(t *testing.T, value interface{}) []byte {
|
||||
t.Helper()
|
||||
b, err := json.Marshal(value)
|
||||
@@ -72,6 +85,26 @@ func postJSON(t *testing.T, client *http.Client, url string, body interface{}, t
|
||||
return resp, out
|
||||
}
|
||||
|
||||
func patchJSON(t *testing.T, client *http.Client, url string, body interface{}, token string) (*http.Response, map[string]interface{}) {
|
||||
t.Helper()
|
||||
req, err := http.NewRequest(http.MethodPatch, url, bytes.NewReader(mustJSON(t, body)))
|
||||
if err != nil {
|
||||
t.Fatalf("new request: %v", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("do request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
out := map[string]interface{}{}
|
||||
_ = json.NewDecoder(resp.Body).Decode(&out)
|
||||
return resp, out
|
||||
}
|
||||
|
||||
func loginUser(t *testing.T, client *http.Client, baseURL, pubB64 string, priv ed25519.PrivateKey) string {
|
||||
t.Helper()
|
||||
chResp, chData := postJSON(t, client, baseURL+"/v1/auth/challenge", map[string]string{"publicKey": pubB64}, "")
|
||||
@@ -251,3 +284,135 @@ func TestCollectionOwnershipIsolation(t *testing.T) {
|
||||
t.Fatalf("expected 403, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssetLifecycleAndVisibility(t *testing.T) {
|
||||
adminPub, adminPriv, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("generate admin key: %v", err)
|
||||
}
|
||||
adminPubB64 := base64.RawURLEncoding.EncodeToString(adminPub)
|
||||
server := newTestServer(adminPubB64)
|
||||
defer server.Close()
|
||||
client := server.Client()
|
||||
client.CheckRedirect = func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse }
|
||||
|
||||
adminToken := loginUser(t, client, server.URL, adminPubB64, adminPriv)
|
||||
|
||||
user1Pub, user1Priv, _ := ed25519.GenerateKey(rand.Reader)
|
||||
user1PubB64 := base64.RawURLEncoding.EncodeToString(user1Pub)
|
||||
registerUserViaAdmin(t, client, server.URL, adminPubB64, adminPriv, adminToken, user1PubB64, user1Priv, "invite-asset-u1")
|
||||
user1Token := loginUser(t, client, server.URL, user1PubB64, user1Priv)
|
||||
|
||||
user2Pub, user2Priv, _ := ed25519.GenerateKey(rand.Reader)
|
||||
user2PubB64 := base64.RawURLEncoding.EncodeToString(user2Pub)
|
||||
registerUserViaAdmin(t, client, server.URL, adminPubB64, adminPriv, adminToken, user2PubB64, user2Priv, "invite-asset-u2")
|
||||
user2Token := loginUser(t, client, server.URL, user2PubB64, user2Priv)
|
||||
|
||||
createCollectionResp, createCollectionData := postJSON(t, client, server.URL+"/v1/collections", map[string]string{
|
||||
"name": "assets",
|
||||
}, user1Token)
|
||||
if createCollectionResp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("create collection status=%d body=%v", createCollectionResp.StatusCode, createCollectionData)
|
||||
}
|
||||
collectionID := createCollectionData["id"].(string)
|
||||
|
||||
createFeatureResp, createFeatureData := postJSON(t, client, server.URL+"/v1/collections/"+collectionID+"/features", map[string]interface{}{
|
||||
"geometry": map[string]interface{}{
|
||||
"type": "Point",
|
||||
"coordinates": []float64{-16.6291, 28.4636, 22},
|
||||
},
|
||||
"properties": map[string]interface{}{
|
||||
"name": "feature-a",
|
||||
},
|
||||
}, user1Token)
|
||||
if createFeatureResp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("create feature status=%d body=%v", createFeatureResp.StatusCode, createFeatureData)
|
||||
}
|
||||
featureID := createFeatureData["id"].(string)
|
||||
|
||||
createAssetResp, createAssetData := postJSON(t, client, server.URL+"/v1/assets", map[string]interface{}{
|
||||
"featureId": featureID,
|
||||
"checksum": "ABCDEF1234",
|
||||
"ext": "glb",
|
||||
"kind": "3d",
|
||||
"mimeType": "model/gltf-binary",
|
||||
"sizeBytes": 100,
|
||||
"name": "Tree",
|
||||
"description": "Public tree",
|
||||
"isPublic": true,
|
||||
}, user1Token)
|
||||
if createAssetResp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("create asset status=%d body=%v", createAssetResp.StatusCode, createAssetData)
|
||||
}
|
||||
asset := createAssetData["asset"].(map[string]interface{})
|
||||
assetID := asset["id"].(string)
|
||||
|
||||
createAssetResp2, createAssetData2 := postJSON(t, client, server.URL+"/v1/assets", map[string]interface{}{
|
||||
"featureId": featureID,
|
||||
"checksum": "abcdef1234",
|
||||
"ext": "glb",
|
||||
"kind": "3d",
|
||||
"name": "Tree v2",
|
||||
}, user1Token)
|
||||
if createAssetResp2.StatusCode != http.StatusOK {
|
||||
t.Fatalf("dedup create asset status=%d body=%v", createAssetResp2.StatusCode, createAssetData2)
|
||||
}
|
||||
asset2 := createAssetData2["asset"].(map[string]interface{})
|
||||
if asset2["id"].(string) != assetID {
|
||||
t.Fatalf("expected dedup asset id=%s got=%s", assetID, asset2["id"].(string))
|
||||
}
|
||||
|
||||
uploadResp, uploadData := postJSON(t, client, server.URL+"/v1/assets/"+assetID+"/signed-upload", map[string]interface{}{
|
||||
"contentType": "model/gltf-binary",
|
||||
}, user1Token)
|
||||
if uploadResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("signed upload status=%d body=%v", uploadResp.StatusCode, uploadData)
|
||||
}
|
||||
|
||||
featuresResp, featuresData := getJSON(t, client, server.URL+"/v1/collections/"+collectionID+"/features", user1Token)
|
||||
if featuresResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("list features status=%d body=%v", featuresResp.StatusCode, featuresData)
|
||||
}
|
||||
features := featuresData["features"].([]interface{})
|
||||
firstFeature := features[0].(map[string]interface{})
|
||||
properties := firstFeature["properties"].(map[string]interface{})
|
||||
assets := properties["assets"].([]interface{})
|
||||
if len(assets) != 1 {
|
||||
t.Fatalf("expected 1 linked asset, got %d", len(assets))
|
||||
}
|
||||
assetView := assets[0].(map[string]interface{})
|
||||
if assetView["link"] != "/v1/assets/"+assetID+"/download" {
|
||||
t.Fatalf("unexpected asset link: %v", assetView["link"])
|
||||
}
|
||||
|
||||
reqDownloadPublic, _ := http.NewRequest(http.MethodGet, server.URL+"/v1/assets/"+assetID+"/download", nil)
|
||||
reqDownloadPublic.Header.Set("Authorization", "Bearer "+user2Token)
|
||||
downloadPublicResp, err := client.Do(reqDownloadPublic)
|
||||
if err != nil {
|
||||
t.Fatalf("download public request failed: %v", err)
|
||||
}
|
||||
if downloadPublicResp.StatusCode != http.StatusFound {
|
||||
t.Fatalf("expected public asset redirect status, got %d", downloadPublicResp.StatusCode)
|
||||
}
|
||||
expectedLocation := fmt.Sprintf("http://files.local/download/%s/%s.%s", user1PubB64, "abcdef1234", "glb")
|
||||
if downloadPublicResp.Header.Get("Location") != expectedLocation {
|
||||
t.Fatalf("unexpected redirect location: %s", downloadPublicResp.Header.Get("Location"))
|
||||
}
|
||||
|
||||
patchResp, patchData := patchJSON(t, client, server.URL+"/v1/assets/"+assetID, map[string]interface{}{
|
||||
"isPublic": false,
|
||||
}, user1Token)
|
||||
if patchResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("patch asset status=%d body=%v", patchResp.StatusCode, patchData)
|
||||
}
|
||||
|
||||
reqDownloadPrivate, _ := http.NewRequest(http.MethodGet, server.URL+"/v1/assets/"+assetID+"/download", nil)
|
||||
reqDownloadPrivate.Header.Set("Authorization", "Bearer "+user2Token)
|
||||
downloadPrivateResp, err := client.Do(reqDownloadPrivate)
|
||||
if err != nil {
|
||||
t.Fatalf("download private request failed: %v", err)
|
||||
}
|
||||
if downloadPrivateResp.StatusCode != http.StatusForbidden {
|
||||
t.Fatalf("expected 403 for private asset, got %d", downloadPrivateResp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user