Files
backend/internal/http/api_test.go
Andriy Oblivantsev 5716d4adf6
CI / test (push) Successful in 4s
Enable moving own features on MapLibre and switch to raster tiles.
Add feature geometry PATCH API support and update MapLibre demo to use OSM raster tiles, load all public/owned features, and let logged-in users drag their own feature markers to persist new positions.

Made-with: Cursor
2026-03-02 22:28:44 +00:00

574 lines
21 KiB
Go

package httpapi_test
import (
"bytes"
"context"
"crypto/ed25519"
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"testing"
"time"
"momswap/backend/internal/app"
httpapi "momswap/backend/internal/http"
"momswap/backend/internal/store"
)
func newTestServer(adminPublicKey string) *httptest.Server {
memory := store.NewMemoryStore()
svc := app.NewService(memory, app.Config{
ChallengeTTL: 5 * time.Minute,
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) SignedGetObjectURL(_ context.Context, objectKey string, _ time.Duration) (string, error) {
return "http://files.local/download/" + objectKey, nil
}
func (fakeSigner) PutObject(_ context.Context, _ string, _ string, _ io.Reader, _ int64) error {
return nil
}
func (fakeSigner) GetObject(_ context.Context, objectKey string) (io.ReadCloser, string, int64, error) {
payload := []byte("fake-download:" + objectKey)
return io.NopCloser(bytes.NewReader(payload)), "application/octet-stream", int64(len(payload)), nil
}
func mustJSON(t *testing.T, value interface{}) []byte {
t.Helper()
b, err := json.Marshal(value)
if err != nil {
t.Fatalf("marshal json: %v", err)
}
return b
}
func getJSON(t *testing.T, client *http.Client, url string, token string) (*http.Response, map[string]interface{}) {
t.Helper()
req, _ := http.NewRequest(http.MethodGet, url, nil)
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 postJSON(t *testing.T, client *http.Client, url string, body interface{}, token string) (*http.Response, map[string]interface{}) {
t.Helper()
req, err := http.NewRequest(http.MethodPost, 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 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 putRaw(t *testing.T, client *http.Client, url string, payload []byte, contentType string, token string) *http.Response {
t.Helper()
req, err := http.NewRequest(http.MethodPut, url, bytes.NewReader(payload))
if err != nil {
t.Fatalf("new request: %v", err)
}
if contentType != "" {
req.Header.Set("Content-Type", contentType)
}
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
resp, err := client.Do(req)
if err != nil {
t.Fatalf("do request: %v", err)
}
return resp
}
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}, "")
if chResp.StatusCode != http.StatusOK {
t.Fatalf("create challenge status=%d body=%v", chResp.StatusCode, chData)
}
nonce := chData["nonce"].(string)
sig := ed25519.Sign(priv, []byte("login:"+nonce))
loginResp, loginData := postJSON(t, client, baseURL+"/v1/auth/login", map[string]string{
"publicKey": pubB64,
"nonce": nonce,
"signature": base64.RawURLEncoding.EncodeToString(sig),
}, "")
if loginResp.StatusCode != http.StatusOK {
t.Fatalf("login status=%d body=%v", loginResp.StatusCode, loginData)
}
return loginData["accessToken"].(string)
}
func registerUserViaAdmin(t *testing.T, client *http.Client, baseURL, adminPub string, adminPriv ed25519.PrivateKey, adminToken string, userPub string, userPriv ed25519.PrivateKey, jti string) {
t.Helper()
payload := app.InvitationPayload{
JTI: jti,
InviterPublicKey: adminPub,
ExpiresAtUnix: time.Now().Add(time.Hour).Unix(),
MaxUses: 1,
}
payloadRaw := mustJSON(t, payload)
payloadB64 := base64.RawURLEncoding.EncodeToString(payloadRaw)
inviteSig := base64.RawURLEncoding.EncodeToString(ed25519.Sign(adminPriv, []byte("invite:"+payloadB64)))
inviteResp, inviteData := postJSON(t, client, baseURL+"/v1/invitations", map[string]string{
"invitePayloadB64": payloadB64,
"inviteSignature": inviteSig,
}, adminToken)
if inviteResp.StatusCode != http.StatusCreated {
t.Fatalf("create invitation status=%d body=%v", inviteResp.StatusCode, inviteData)
}
proofSig := base64.RawURLEncoding.EncodeToString(ed25519.Sign(userPriv, []byte("register:"+userPub+":"+jti)))
registerResp, registerData := postJSON(t, client, baseURL+"/v1/auth/register", map[string]string{
"publicKey": userPub,
"invitePayloadB64": payloadB64,
"inviteSignature": inviteSig,
"proofSignature": proofSig,
}, "")
if registerResp.StatusCode != http.StatusCreated {
t.Fatalf("register status=%d body=%v", registerResp.StatusCode, registerData)
}
}
func TestRegisterBySignature(t *testing.T) {
adminPub, _, 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()
svcKeyResp, svcKeyData := getJSON(t, client, server.URL+"/v1/service-key", "")
if svcKeyResp.StatusCode != http.StatusOK {
t.Fatalf("get service key status=%d body=%v", svcKeyResp.StatusCode, svcKeyData)
}
if svcKeyData["publicKey"] != adminPubB64 {
t.Fatalf("service key mismatch: got %v", svcKeyData["publicKey"])
}
userPub, userPriv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("generate user key: %v", err)
}
userPubB64 := base64.RawURLEncoding.EncodeToString(userPub)
sig := base64.RawURLEncoding.EncodeToString(ed25519.Sign(userPriv, []byte(adminPubB64)))
regResp, regData := postJSON(t, client, server.URL+"/v1/auth/register-by-signature", map[string]string{
"publicKey": userPubB64,
"signature": sig,
}, "")
if regResp.StatusCode != http.StatusCreated {
t.Fatalf("register-by-signature status=%d body=%v", regResp.StatusCode, regData)
}
userToken := loginUser(t, client, server.URL, userPubB64, userPriv)
if userToken == "" {
t.Fatal("login after register-by-signature failed")
}
}
func TestRegisterLoginAndProfile(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()
adminToken := loginUser(t, client, server.URL, adminPubB64, adminPriv)
userPub, userPriv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("generate user key: %v", err)
}
userPubB64 := base64.RawURLEncoding.EncodeToString(userPub)
registerUserViaAdmin(t, client, server.URL, adminPubB64, adminPriv, adminToken, userPubB64, userPriv, "invite-1")
userToken := loginUser(t, client, server.URL, userPubB64, userPriv)
req, _ := http.NewRequest(http.MethodGet, server.URL+"/v1/me/keys", nil)
req.Header.Set("Authorization", "Bearer "+userToken)
resp, err := client.Do(req)
if err != nil {
t.Fatalf("me request: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("me status=%d", resp.StatusCode)
}
}
func TestCollectionOwnershipIsolation(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()
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-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-u2")
user2Token := loginUser(t, client, server.URL, user2PubB64, user2Priv)
createCollectionResp, createCollectionData := postJSON(t, client, server.URL+"/v1/collections", map[string]string{
"name": "my places",
}, 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},
},
"properties": map[string]interface{}{
"name": "Santa Cruz",
},
}, user1Token)
if createFeatureResp.StatusCode != http.StatusCreated {
t.Fatalf("create feature status=%d body=%v", createFeatureResp.StatusCode, createFeatureData)
}
req, _ := http.NewRequest(http.MethodGet, server.URL+"/v1/collections/"+collectionID+"/features", nil)
req.Header.Set("Authorization", "Bearer "+user2Token)
resp, err := client.Do(req)
if err != nil {
t.Fatalf("list features as user2: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("expected 403, got %d", resp.StatusCode)
}
featureID := createFeatureData["id"].(string)
patchOwnResp, patchOwnData := patchJSON(t, client, server.URL+"/v1/features/"+featureID, map[string]interface{}{
"geometry": map[string]interface{}{
"type": "Point",
"coordinates": []float64{-16.6299, 28.4639, 11},
},
}, user1Token)
if patchOwnResp.StatusCode != http.StatusOK {
t.Fatalf("owner patch feature status=%d body=%v", patchOwnResp.StatusCode, patchOwnData)
}
patchOtherResp, patchOtherData := patchJSON(t, client, server.URL+"/v1/features/"+featureID, map[string]interface{}{
"geometry": map[string]interface{}{
"type": "Point",
"coordinates": []float64{-16.6301, 28.4641, 12},
},
}, user2Token)
if patchOtherResp.StatusCode != http.StatusForbidden {
t.Fatalf("non-owner patch feature status=%d body=%v", patchOtherResp.StatusCode, patchOtherData)
}
}
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()
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")
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)
}
if uploadData["url"] != "/v1/assets/"+assetID+"/upload" {
t.Fatalf("unexpected signed-upload backend url: %v", uploadData["url"])
}
putResp := putRaw(t, client, server.URL+"/v1/assets/"+assetID+"/upload", []byte("glb-bytes"), "model/gltf-binary", user1Token)
defer putResp.Body.Close()
if putResp.StatusCode != http.StatusNoContent {
t.Fatalf("upload proxy status=%d", putResp.StatusCode)
}
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)
downloadPublicResp, err := client.Do(reqDownloadPublic)
if err != nil {
t.Fatalf("download public request failed: %v", err)
}
defer downloadPublicResp.Body.Close()
if downloadPublicResp.StatusCode != http.StatusOK {
t.Fatalf("expected public asset stream status, got %d", downloadPublicResp.StatusCode)
}
body, err := io.ReadAll(downloadPublicResp.Body)
if err != nil {
t.Fatalf("read public download body: %v", err)
}
expectedBody := fmt.Sprintf("fake-download:%s/%s.%s", user1PubB64, "abcdef1234", "glb")
if string(body) != expectedBody {
t.Fatalf("unexpected download body: %q", string(body))
}
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)
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)
}
}
func TestListPublicFeatures(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()
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-public-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-public-u2")
user2Token := loginUser(t, client, server.URL, user2PubB64, user2Priv)
c1Resp, c1Data := postJSON(t, client, server.URL+"/v1/collections", map[string]string{"name": "u1-public"}, user1Token)
if c1Resp.StatusCode != http.StatusCreated {
t.Fatalf("u1 create collection status=%d body=%v", c1Resp.StatusCode, c1Data)
}
c1ID := c1Data["id"].(string)
f1Resp, f1Data := postJSON(t, client, server.URL+"/v1/collections/"+c1ID+"/features", map[string]interface{}{
"geometry": map[string]interface{}{"type": "Point", "coordinates": []float64{-16.25, 28.46, 5}},
"properties": map[string]interface{}{
"name": "u1-public-feature",
},
}, user1Token)
if f1Resp.StatusCode != http.StatusCreated {
t.Fatalf("u1 create feature status=%d body=%v", f1Resp.StatusCode, f1Data)
}
f1ID := f1Data["id"].(string)
a1Resp, a1Data := postJSON(t, client, server.URL+"/v1/assets", map[string]interface{}{
"featureId": f1ID,
"checksum": "pub3d111",
"ext": "glb",
"kind": "3d",
"isPublic": true,
}, user1Token)
if a1Resp.StatusCode != http.StatusCreated {
t.Fatalf("u1 create public asset status=%d body=%v", a1Resp.StatusCode, a1Data)
}
a1ID := a1Data["asset"].(map[string]interface{})["id"].(string)
c2Resp, c2Data := postJSON(t, client, server.URL+"/v1/collections", map[string]string{"name": "u2-private"}, user2Token)
if c2Resp.StatusCode != http.StatusCreated {
t.Fatalf("u2 create collection status=%d body=%v", c2Resp.StatusCode, c2Data)
}
c2ID := c2Data["id"].(string)
f2Resp, f2Data := postJSON(t, client, server.URL+"/v1/collections/"+c2ID+"/features", map[string]interface{}{
"geometry": map[string]interface{}{"type": "Point", "coordinates": []float64{-16.3, 28.47, 7}},
"properties": map[string]interface{}{
"name": "u2-private-feature",
},
}, user2Token)
if f2Resp.StatusCode != http.StatusCreated {
t.Fatalf("u2 create feature status=%d body=%v", f2Resp.StatusCode, f2Data)
}
f2ID := f2Data["id"].(string)
a2Resp, a2Data := postJSON(t, client, server.URL+"/v1/assets", map[string]interface{}{
"featureId": f2ID,
"checksum": "priv3d222",
"ext": "glb",
"kind": "3d",
"isPublic": false,
}, user2Token)
if a2Resp.StatusCode != http.StatusCreated {
t.Fatalf("u2 create private asset status=%d body=%v", a2Resp.StatusCode, a2Data)
}
publicResp, publicData := getJSON(t, client, server.URL+"/v1/features/public?kind=3d", "")
if publicResp.StatusCode != http.StatusOK {
t.Fatalf("list public features status=%d body=%v", publicResp.StatusCode, publicData)
}
publicFeatures := publicData["features"].([]interface{})
if len(publicFeatures) != 1 {
t.Fatalf("expected 1 public feature, got %d", len(publicFeatures))
}
publicFeature := publicFeatures[0].(map[string]interface{})
if publicFeature["id"].(string) != f1ID {
t.Fatalf("expected public feature id=%s got=%v", f1ID, publicFeature["id"])
}
properties := publicFeature["properties"].(map[string]interface{})
assets := properties["assets"].([]interface{})
if len(assets) != 1 {
t.Fatalf("expected 1 public asset, got %d", len(assets))
}
publicAsset := assets[0].(map[string]interface{})
if publicAsset["id"].(string) != a1ID {
t.Fatalf("expected public asset id=%s got=%v", a1ID, publicAsset["id"])
}
}