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 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) } } 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) } 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) 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) } }