Files
backend/internal/http/api_test.go
Andriy Oblivantsev 0c76e867ae
CI / test (push) Successful in 4s
Allow anonymous public asset downloads and adopt OSM Liberty style for MapLibre demo.
Public asset links now work without bearer auth while private assets remain protected, and the demo ships with the local osm-liberty-gl-style assets for consistent tile rendering.

Made-with: Cursor
2026-03-02 21:56:31 +00:00

445 lines
16 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 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")
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)
}
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)
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)
}
}