Integrate asset metadata/storage support, TypeScript client asset APIs, docs updates, and the Leaflet demo while resolving conflicts with recent challenge IP/login persistence changes on main. 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,10 @@ func (a *API) Routes() http.Handler {
|
||||
mux.HandleFunc("POST /v1/collections/{id}/features", a.createFeature)
|
||||
mux.HandleFunc("GET /v1/collections/{id}/features", a.listFeatures)
|
||||
mux.HandleFunc("DELETE /v1/features/{id}", a.deleteFeature)
|
||||
mux.HandleFunc("POST /v1/assets", a.createAsset)
|
||||
mux.HandleFunc("PATCH /v1/assets/{id}", a.patchAsset)
|
||||
mux.HandleFunc("POST /v1/assets/{id}/signed-upload", a.signedUpload)
|
||||
mux.HandleFunc("GET /v1/assets/{id}/download", a.downloadAsset)
|
||||
|
||||
mux.Handle("/web/", http.StripPrefix("/web/", staticFiles))
|
||||
mux.Handle("/libs/", http.StripPrefix("/libs/", libsFiles))
|
||||
@@ -97,8 +101,11 @@ func statusFromErr(err error) int {
|
||||
errors.Is(err, app.ErrInviteExhaust):
|
||||
return http.StatusBadRequest
|
||||
case errors.Is(err, app.ErrCollectionMiss), errors.Is(err, app.ErrFeatureMiss),
|
||||
errors.Is(err, app.ErrAssetMiss),
|
||||
errors.Is(err, store.ErrNotFound):
|
||||
return http.StatusNotFound
|
||||
case errors.Is(err, app.ErrStorageNotConfigured):
|
||||
return http.StatusServiceUnavailable
|
||||
default:
|
||||
return http.StatusInternalServerError
|
||||
}
|
||||
@@ -361,3 +368,108 @@ func (a *API) deleteFeature(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (a *API) createAsset(w http.ResponseWriter, r *http.Request) {
|
||||
user, err := a.authUser(r)
|
||||
if err != nil {
|
||||
writeErr(w, err)
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
FeatureID string `json:"featureId"`
|
||||
Checksum string `json:"checksum"`
|
||||
Ext string `json:"ext"`
|
||||
Kind string `json:"kind"`
|
||||
MimeType string `json:"mimeType"`
|
||||
SizeBytes int64 `json:"sizeBytes"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
IsPublic *bool `json:"isPublic"`
|
||||
}
|
||||
if err := readJSON(r, &req); err != nil {
|
||||
writeErr(w, app.ErrBadRequest)
|
||||
return
|
||||
}
|
||||
asset, created, err := a.service.CreateOrLinkAsset(user, app.CreateAssetInput{
|
||||
FeatureID: req.FeatureID,
|
||||
Checksum: req.Checksum,
|
||||
Ext: req.Ext,
|
||||
Kind: req.Kind,
|
||||
MimeType: req.MimeType,
|
||||
SizeBytes: req.SizeBytes,
|
||||
Name: req.Name,
|
||||
Description: req.Description,
|
||||
Visibility: req.IsPublic,
|
||||
})
|
||||
if err != nil {
|
||||
writeErr(w, err)
|
||||
return
|
||||
}
|
||||
status := http.StatusOK
|
||||
if created {
|
||||
status = http.StatusCreated
|
||||
}
|
||||
writeJSON(w, status, map[string]interface{}{
|
||||
"asset": asset,
|
||||
"link": "/v1/assets/" + asset.ID + "/download",
|
||||
})
|
||||
}
|
||||
|
||||
func (a *API) patchAsset(w http.ResponseWriter, r *http.Request) {
|
||||
user, err := a.authUser(r)
|
||||
if err != nil {
|
||||
writeErr(w, err)
|
||||
return
|
||||
}
|
||||
assetID := r.PathValue("id")
|
||||
var req struct {
|
||||
IsPublic bool `json:"isPublic"`
|
||||
}
|
||||
if err := readJSON(r, &req); err != nil {
|
||||
writeErr(w, app.ErrBadRequest)
|
||||
return
|
||||
}
|
||||
asset, err := a.service.SetAssetPublic(user, assetID, req.IsPublic)
|
||||
if err != nil {
|
||||
writeErr(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"asset": asset, "link": "/v1/assets/" + asset.ID + "/download"})
|
||||
}
|
||||
|
||||
func (a *API) signedUpload(w http.ResponseWriter, r *http.Request) {
|
||||
user, err := a.authUser(r)
|
||||
if err != nil {
|
||||
writeErr(w, err)
|
||||
return
|
||||
}
|
||||
assetID := r.PathValue("id")
|
||||
var req struct {
|
||||
ContentType string `json:"contentType"`
|
||||
}
|
||||
if err := readJSON(r, &req); err != nil {
|
||||
writeErr(w, app.ErrBadRequest)
|
||||
return
|
||||
}
|
||||
url, err := a.service.SignedUploadURL(user, assetID, req.ContentType)
|
||||
if err != nil {
|
||||
writeErr(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"url": url, "method": http.MethodPut})
|
||||
}
|
||||
|
||||
func (a *API) downloadAsset(w http.ResponseWriter, r *http.Request) {
|
||||
user, err := a.authUser(r)
|
||||
if err != nil {
|
||||
writeErr(w, err)
|
||||
return
|
||||
}
|
||||
assetID := r.PathValue("id")
|
||||
url, err := a.service.SignedDownloadURL(user, assetID)
|
||||
if err != nil {
|
||||
writeErr(w, err)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, url, http.StatusFound)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user