Files
updates/internal/handlers/compat_test.go
lan a0ef7f430d Initial updates server repository commit.
Reinitialize repository history and exclude generated OTA artifact outputs.

Made-with: Cursor
2026-03-09 21:33:34 +08:00

327 lines
9.7 KiB
Go

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