package handlers_test import ( "crypto/rand" "crypto/rsa" "crypto/x509" "encoding/pem" "io" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" "expo-updates-server-go/internal/app" ) func writeTestPrivateKey(t *testing.T, root string) string { t.Helper() key, err := rsa.GenerateKey(rand.Reader, 2048) if err != nil { t.Fatalf("failed to generate rsa key: %v", err) } privateKeyPath := filepath.Join(root, "privatekey.pem") pemBytes := pem.EncodeToMemory(&pem.Block{ Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key), }) if err := os.WriteFile(privateKeyPath, pemBytes, 0o600); err != nil { t.Fatalf("failed to write private key: %v", err) } return privateKeyPath } func writeTestRelease(t *testing.T, updatesRoot string, platform string) { t.Helper() releaseDir := filepath.Join(updatesRoot, "test", platform) if err := os.MkdirAll(filepath.Join(releaseDir, "assets"), 0o755); err != nil { t.Fatalf("failed to create assets dir: %v", err) } if err := os.MkdirAll(filepath.Join(releaseDir, "bundles"), 0o755); err != nil { t.Fatalf("failed to create bundles dir: %v", err) } metadata := `{ "version": 0, "fileMetadata": { "ios": { "bundle": "bundles/ios-9d01842d6ee1224f7188971c5d397115.js", "assets": [ { "path": "assets/4f1cb2cac2370cd5050681232e8575a8", "ext": "png" } ] }, "android": { "bundle": "bundles/ios-9d01842d6ee1224f7188971c5d397115.js", "assets": [ { "path": "assets/4f1cb2cac2370cd5050681232e8575a8", "ext": "png" } ] } } } ` if err := os.WriteFile(filepath.Join(releaseDir, "metadata.json"), []byte(metadata), 0o644); err != nil { t.Fatalf("failed to write metadata: %v", err) } expoConfig := `{"name":"compat-test","slug":"compat-test"}` if err := os.WriteFile(filepath.Join(releaseDir, "expoConfig.json"), []byte(expoConfig), 0o644); err != nil { t.Fatalf("failed to write expoConfig: %v", err) } if err := os.WriteFile( filepath.Join(releaseDir, "assets", "4f1cb2cac2370cd5050681232e8575a8"), []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A}, 0o644, ); err != nil { t.Fatalf("failed to write test png asset: %v", err) } if err := os.WriteFile( filepath.Join(releaseDir, "bundles", "ios-9d01842d6ee1224f7188971c5d397115.js"), []byte("console.log('compat test bundle');"), 0o644, ); err != nil { t.Fatalf("failed to write test js bundle: %v", err) } } func writeRollbackRelease(t *testing.T, updatesRoot string, platform string) { t.Helper() releaseDir := filepath.Join(updatesRoot, "testrollback", platform) if err := os.MkdirAll(releaseDir, 0o755); err != nil { t.Fatalf("failed to create rollback release dir: %v", err) } if err := os.WriteFile(filepath.Join(releaseDir, "rollback"), []byte{}, 0o644); err != nil { t.Fatalf("failed to write rollback marker: %v", err) } } func setupTestFixtures(t *testing.T) (string, string) { t.Helper() tmpRoot := t.TempDir() updatesRoot := filepath.Join(tmpRoot, "updates") if err := os.MkdirAll(updatesRoot, 0o755); err != nil { t.Fatalf("failed to create updates root: %v", err) } writeTestRelease(t, updatesRoot, "ios") writeTestRelease(t, updatesRoot, "android") writeRollbackRelease(t, updatesRoot, "ios") writeRollbackRelease(t, updatesRoot, "android") privateKeyPath := writeTestPrivateKey(t, tmpRoot) return updatesRoot, privateKeyPath } func newTestServer(t *testing.T) (http.Handler, string) { t.Helper() updatesRoot, privateKeyPath := setupTestFixtures(t) cfg := app.Config{ Port: "0", Hostname: "http://localhost:3001", UpdatesRoot: updatesRoot, PrivateKey: privateKeyPath, AdminToken: "dev-token", } return app.NewServer(cfg).Routes(), updatesRoot } func doReq(t *testing.T, h http.Handler, method string, target string, headers map[string]string) *httptest.ResponseRecorder { t.Helper() req := httptest.NewRequest(method, target, nil) for k, v := range headers { req.Header.Set(k, v) } rec := httptest.NewRecorder() h.ServeHTTP(rec, req) return rec } func mustBodyString(t *testing.T, rec *httptest.ResponseRecorder) string { t.Helper() b, err := io.ReadAll(rec.Result().Body) if err != nil { t.Fatal(err) } return string(b) } func TestManifestReturns405WithPOST(t *testing.T) { h, _ := newTestServer(t) rec := doReq(t, h, http.MethodPost, "/api/manifest", nil) if rec.Code != http.StatusMethodNotAllowed { t.Fatalf("expected 405, got %d", rec.Code) } } func TestManifestReturns400WithUnsupportedPlatform(t *testing.T) { h, _ := newTestServer(t) rec := doReq(t, h, http.MethodGet, "/api/manifest", map[string]string{ "expo-platform": "unsupported-platform", "expo-runtime-version": "test", }) if rec.Code != http.StatusBadRequest { t.Fatalf("expected 400, got %d", rec.Code) } } func TestManifestReturns404WithUnknownRuntimeVersion(t *testing.T) { h, _ := newTestServer(t) rec := doReq(t, h, http.MethodGet, "/api/manifest", map[string]string{ "expo-platform": "ios", "expo-runtime-version": "999", }) if rec.Code != http.StatusNotFound { t.Fatalf("expected 404, got %d", rec.Code) } } func TestManifestReturnsLatestIOSAndAndroid(t *testing.T) { h, _ := newTestServer(t) cases := []string{"ios", "android"} for _, platform := range cases { for _, protocol := range []string{"0", "1"} { rec := doReq(t, h, http.MethodGet, "/api/manifest", map[string]string{ "expo-platform": platform, "expo-runtime-version": "test", "expo-protocol-version": protocol, "expo-expect-signature": "true", }) if rec.Code != http.StatusOK { t.Fatalf("expected 200, got %d for platform=%s protocol=%s", rec.Code, platform, protocol) } contentType := rec.Header().Get("content-type") if !strings.HasPrefix(contentType, "multipart/mixed; boundary=") { t.Fatalf("unexpected content-type: %s", contentType) } body := mustBodyString(t, rec) if !strings.Contains(body, "name=\"manifest\"") { t.Fatalf("manifest part missing for platform=%s protocol=%s", platform, protocol) } if !strings.Contains(body, "\"runtimeVersion\":\"test\"") { t.Fatalf("runtimeVersion mismatch for platform=%s protocol=%s", platform, protocol) } if !strings.Contains(body, "expo-signature:") { t.Fatalf("signature missing for platform=%s protocol=%s", platform, protocol) } if !strings.Contains(body, "\"launchAsset\"") || !strings.Contains(body, "\"assets\"") { t.Fatalf("assets payload missing for platform=%s protocol=%s", platform, protocol) } } } } func TestManifestReturnsRollbackForProtocol1(t *testing.T) { h, _ := newTestServer(t) for _, platform := range []string{"ios", "android"} { rec := doReq(t, h, http.MethodGet, "/api/manifest", map[string]string{ "expo-platform": platform, "expo-runtime-version": "testrollback", "expo-protocol-version": "1", "expo-expect-signature": "true", "expo-embedded-update-id": "123", }) if rec.Code != http.StatusOK { t.Fatalf("expected 200, got %d for platform=%s", rec.Code, platform) } body := mustBodyString(t, rec) if !strings.Contains(body, "name=\"directive\"") || !strings.Contains(body, "rollBackToEmbedded") { t.Fatalf("rollback directive missing for platform=%s", platform) } } } func TestManifestRollbackWithProtocol0Returns404(t *testing.T) { h, _ := newTestServer(t) for _, platform := range []string{"ios", "android"} { rec := doReq(t, h, http.MethodGet, "/api/manifest", map[string]string{ "expo-platform": platform, "expo-runtime-version": "testrollback", "expo-expect-signature": "true", "expo-embedded-update-id": "123", }) if rec.Code != http.StatusNotFound { t.Fatalf("expected 404, got %d for platform=%s", rec.Code, platform) } } } func TestAssetsReturnsAssetAndLaunchAssetFile(t *testing.T) { h, updatesRoot := newTestServer(t) assetPath := filepath.Join(updatesRoot, "test", "ios", "assets", "4f1cb2cac2370cd5050681232e8575a8") launchPath := filepath.Join(updatesRoot, "test", "ios", "bundles", "ios-9d01842d6ee1224f7188971c5d397115.js") assetReq := doReq( t, h, http.MethodGet, "/api/assets?runtimeVersion=test&asset="+assetPath+"&platform=ios", nil, ) if assetReq.Code != http.StatusOK { t.Fatalf("expected 200, got %d for image asset", assetReq.Code) } if assetReq.Header().Get("content-type") != "image/png" { t.Fatalf("expected image/png, got %s", assetReq.Header().Get("content-type")) } launchReq := doReq( t, h, http.MethodGet, "/api/assets?runtimeVersion=test&asset="+launchPath+"&platform=ios", nil, ) if launchReq.Code != http.StatusOK { t.Fatalf("expected 200, got %d for launch asset", launchReq.Code) } if launchReq.Header().Get("content-type") != "application/javascript" { t.Fatalf("expected application/javascript, got %s", launchReq.Header().Get("content-type")) } } func TestAssetsErrorCases(t *testing.T) { h, _ := newTestServer(t) cases := []struct { name string target string code int }{ { name: "no asset", target: "/api/assets?runtimeVersion=test&platform=ios", code: http.StatusBadRequest, }, { name: "no runtime version", target: "/api/assets?asset=updates/1/assets/does-not-exist.png&platform=ios", code: http.StatusBadRequest, }, { name: "no platform", target: "/api/assets?asset=updates/1/assets/does-not-exist.png&runtimeVersion=test", code: http.StatusBadRequest, }, { name: "asset not exists", target: "/api/assets?asset=updates/1/assets/does-not-exist.png&runtimeVersion=test&platform=ios", code: http.StatusNotFound, }, } for _, c := range cases { rec := doReq(t, h, http.MethodGet, c.target, nil) if rec.Code != c.code { t.Fatalf("%s: expected %d, got %d", c.name, c.code, rec.Code) } } }