feat(admin): 添加APK管理功能
实现APK文件的上传、下载和列表功能 - 新增APKInfo数据结构 - 添加/admin/apk/upload、/admin/apk/download和/admin/apk/list路由 - 实现APK存储服务方法 - 添加相关测试用例
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
package handlers_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
@@ -324,3 +325,107 @@ func TestAssetsErrorCases(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func doReqWithBody(t *testing.T, h http.Handler, method string, target string, headers map[string]string, body []byte) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(method, target, bytes.NewReader(body))
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
func TestAPKUploadRequiresAuth(t *testing.T) {
|
||||
h, _ := newTestServer(t)
|
||||
rec := doReqWithBody(t, h, http.MethodPost, "/admin/apk/upload?runtimeVersion=1.0.0", nil, []byte("fake-apk"))
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPKUploadAndDownload(t *testing.T) {
|
||||
h, updatesRoot := newTestServer(t)
|
||||
authHeaders := map[string]string{"Authorization": "Bearer dev-token"}
|
||||
|
||||
fakeAPK := []byte("PK\x03\x04fake-apk-content-for-testing")
|
||||
uploadRec := doReqWithBody(t, h, http.MethodPost, "/admin/apk/upload?runtimeVersion=1.0.0", map[string]string{
|
||||
"Authorization": "Bearer dev-token",
|
||||
"Content-Type": "application/vnd.android.package-archive",
|
||||
}, fakeAPK)
|
||||
if uploadRec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", uploadRec.Code, mustBodyString(t, uploadRec))
|
||||
}
|
||||
|
||||
apkPath := filepath.Join(updatesRoot, "apk", "1.0.0.apk")
|
||||
if _, err := os.Stat(apkPath); os.IsNotExist(err) {
|
||||
t.Fatalf("APK file not created at %s", apkPath)
|
||||
}
|
||||
|
||||
downloadRec := doReq(t, h, http.MethodGet, "/admin/apk/download?runtimeVersion=1.0.0", authHeaders)
|
||||
if downloadRec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", downloadRec.Code)
|
||||
}
|
||||
if downloadRec.Header().Get("Content-Type") != "application/vnd.android.package-archive" {
|
||||
t.Fatalf("expected application/vnd.android.package-archive, got %s", downloadRec.Header().Get("Content-Type"))
|
||||
}
|
||||
if downloadRec.Header().Get("Content-Disposition") != "attachment; filename=1.0.0.apk" {
|
||||
t.Fatalf("unexpected Content-Disposition: %s", downloadRec.Header().Get("Content-Disposition"))
|
||||
}
|
||||
body := mustBodyString(t, downloadRec)
|
||||
if body != string(fakeAPK) {
|
||||
t.Fatalf("downloaded content mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPKDownloadNotFound(t *testing.T) {
|
||||
h, _ := newTestServer(t)
|
||||
authHeaders := map[string]string{"Authorization": "Bearer dev-token"}
|
||||
rec := doReq(t, h, http.MethodGet, "/admin/apk/download?runtimeVersion=nonexistent", authHeaders)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPKList(t *testing.T) {
|
||||
h, _ := newTestServer(t)
|
||||
authHeaders := map[string]string{"Authorization": "Bearer dev-token"}
|
||||
|
||||
listRec := doReq(t, h, http.MethodGet, "/admin/apk/list", authHeaders)
|
||||
if listRec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", listRec.Code)
|
||||
}
|
||||
body := mustBodyString(t, listRec)
|
||||
if !strings.Contains(body, `"apks"`) {
|
||||
t.Fatalf("expected apks array in response: %s", body)
|
||||
}
|
||||
|
||||
fakeAPK := []byte("PK\x03\x04fake-apk-content")
|
||||
uploadRec := doReqWithBody(t, h, http.MethodPost, "/admin/apk/upload?runtimeVersion=2.0.0", map[string]string{
|
||||
"Authorization": "Bearer dev-token",
|
||||
"Content-Type": "application/octet-stream",
|
||||
}, fakeAPK)
|
||||
if uploadRec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", uploadRec.Code)
|
||||
}
|
||||
|
||||
listRec2 := doReq(t, h, http.MethodGet, "/admin/apk/list", authHeaders)
|
||||
if listRec2.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", listRec2.Code)
|
||||
}
|
||||
body2 := mustBodyString(t, listRec2)
|
||||
if !strings.Contains(body2, "2.0.0") {
|
||||
t.Fatalf("expected 2.0.0 in response: %s", body2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPKUploadRequiresRuntimeVersion(t *testing.T) {
|
||||
h, _ := newTestServer(t)
|
||||
rec := doReqWithBody(t, h, http.MethodPost, "/admin/apk/upload", map[string]string{
|
||||
"Authorization": "Bearer dev-token",
|
||||
"Content-Type": "application/vnd.android.package-archive",
|
||||
}, []byte("fake"))
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,6 +212,78 @@ func (h *Handler) AdminReleases(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"releases": releases})
|
||||
}
|
||||
|
||||
func (h *Handler) AdminUploadAPK(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.authorizeAdmin(w, r) {
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodPost {
|
||||
writeJSON(w, http.StatusMethodNotAllowed, map[string]any{"error": "Expected POST."})
|
||||
return
|
||||
}
|
||||
runtimeVersion := r.URL.Query().Get("runtimeVersion")
|
||||
if runtimeVersion == "" {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]any{"error": "runtimeVersion is required"})
|
||||
return
|
||||
}
|
||||
contentType := r.Header.Get("Content-Type")
|
||||
if !strings.HasPrefix(contentType, "application/vnd.android.package-archive") &&
|
||||
!strings.HasPrefix(contentType, "application/octet-stream") {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]any{"error": "Content-Type must be application/vnd.android.package-archive or application/octet-stream"})
|
||||
return
|
||||
}
|
||||
maxSize := int64(500 * 1024 * 1024)
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxSize)
|
||||
path, err := h.svc.SaveAPK(runtimeVersion, r.Body)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]any{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"runtimeVersion": runtimeVersion,
|
||||
"path": path,
|
||||
"message": "APK uploaded successfully",
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) AdminDownloadAPK(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.authorizeAdmin(w, r) {
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodGet {
|
||||
writeJSON(w, http.StatusMethodNotAllowed, map[string]any{"error": "Expected GET."})
|
||||
return
|
||||
}
|
||||
runtimeVersion := r.URL.Query().Get("runtimeVersion")
|
||||
if runtimeVersion == "" {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]any{"error": "runtimeVersion is required"})
|
||||
return
|
||||
}
|
||||
apkPath, err := h.svc.GetAPKPath(runtimeVersion)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusNotFound, map[string]any{"error": fmt.Sprintf("APK for runtimeVersion %s not found", runtimeVersion)})
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/vnd.android.package-archive")
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s.apk", runtimeVersion))
|
||||
http.ServeFile(w, r, apkPath)
|
||||
}
|
||||
|
||||
func (h *Handler) AdminListAPKs(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.authorizeAdmin(w, r) {
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodGet {
|
||||
writeJSON(w, http.StatusMethodNotAllowed, map[string]any{"error": "Expected GET."})
|
||||
return
|
||||
}
|
||||
apks, err := h.svc.ListAPKs()
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]any{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"apks": apks})
|
||||
}
|
||||
|
||||
func (h *Handler) authorizeAdmin(w http.ResponseWriter, r *http.Request) bool {
|
||||
if h.adminToken == "" {
|
||||
writeJSON(w, http.StatusForbidden, map[string]any{"error": "ADMIN_TOKEN is not configured"})
|
||||
|
||||
Reference in New Issue
Block a user