Implement geo backend, TS client, frontend, and CI tests.
Add a Go HTTP API with Ed25519 auth and invitation onboarding, user-scoped GeoJSON Point management, a Bun-tested @noble/ed25519 TypeScript client, static Vue/Vuetify frontend integration, and a Gitea CI workflow running both Go and Bun test suites. Made-with: Cursor
This commit is contained in:
@@ -0,0 +1,197 @@
|
||||
package httpapi_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"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,
|
||||
})
|
||||
svc.BootstrapAdmin(adminPublicKey)
|
||||
api := httpapi.NewAPI(svc)
|
||||
return httptest.NewServer(api.Routes())
|
||||
}
|
||||
|
||||
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 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 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 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user