Files
Tony 774954fce7 feat: implement patch expiration and encryption features
- Add offline expiration handling for patch artifacts in the database.
- Update CreatePatchArtifact and related functions to accept and return offline expiration timestamps.
- Enhance patch check responses to include offline expiration metadata.
- Introduce device-specific encryption for patches, allowing for secure delivery.
- Implement tests for patch check functionality, including scenarios for expired patches and encrypted delivery.
- Modify router and configuration to support new patch delivery settings.
- Update database schema and migrations to accommodate new fields for offline expiration.
2026-06-24 03:02:09 +08:00

372 lines
12 KiB
Go

package handlers
import (
"bytes"
"context"
"crypto/aes"
"crypto/cipher"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/google/uuid"
"github.com/shorebird-server/internal/db"
"github.com/shorebird-server/internal/models"
"github.com/shorebird-server/internal/storage"
)
const (
testReleaseVersion = "1.0.0+1"
testArch = "x64"
testPlatform = "windows"
)
func TestPatchCheckRemovesExpiredInstalledPatchWhenNoNewPatchExists(t *testing.T) {
fixture := newPatchCheckFixture(t)
fixture.createPatch(t, 1, time.Now().Add(-time.Hour))
response := fixture.patchCheck(t, &models.PatchCheckRequest{
ReleaseVersion: testReleaseVersion,
Platform: testPlatform,
Arch: testArch,
AppID: fixture.appID.String(),
Channel: "stable",
CurrentPatchNumber: intPtr(1),
})
if response.PatchAvailable {
t.Fatalf("expected no patch, got %+v", response.Patch)
}
if response.RemovePatch == nil {
t.Fatal("expected remove_patch metadata")
}
if response.RemovePatch.Number != 1 || response.RemovePatch.Reason != "offline_expired" {
t.Fatalf("unexpected remove_patch: %+v", response.RemovePatch)
}
if !containsInt(response.RolledBackPatchNumbers, 1) {
t.Fatalf("expected rolled_back_patch_numbers to contain 1, got %+v", response.RolledBackPatchNumbers)
}
}
func TestPatchCheckServesNewPatchInsteadOfRemovingExpiredInstalledPatch(t *testing.T) {
fixture := newPatchCheckFixture(t)
fixture.createPatch(t, 1, time.Now().Add(-time.Hour))
fixture.createPatch(t, 2, time.Now().Add(24*time.Hour))
response := fixture.patchCheck(t, &models.PatchCheckRequest{
ReleaseVersion: testReleaseVersion,
Platform: testPlatform,
Arch: testArch,
AppID: fixture.appID.String(),
Channel: "stable",
CurrentPatchNumber: intPtr(1),
})
if !response.PatchAvailable {
t.Fatalf("expected patch 2 to be available, got remove=%+v", response.RemovePatch)
}
if response.Patch == nil || response.Patch.Number != 2 {
t.Fatalf("expected patch number 2, got %+v", response.Patch)
}
if response.RemovePatch != nil {
t.Fatalf("did not expect removal while a newer valid patch exists: %+v", response.RemovePatch)
}
if response.Patch.OfflineExpiresAt == nil {
t.Fatal("expected offline_expires_at on patch metadata")
}
}
func TestPatchCheckDoesNotServeExpiredPatchToUnpatchedDevice(t *testing.T) {
fixture := newPatchCheckFixture(t)
fixture.createPatch(t, 1, time.Now().Add(-time.Hour))
response := fixture.patchCheck(t, &models.PatchCheckRequest{
ReleaseVersion: testReleaseVersion,
Platform: testPlatform,
Arch: testArch,
AppID: fixture.appID.String(),
Channel: "stable",
})
if response.PatchAvailable {
t.Fatalf("expected expired patch to be withheld, got %+v", response.Patch)
}
if response.RemovePatch != nil {
t.Fatalf("unpatched devices should not receive removal metadata: %+v", response.RemovePatch)
}
if len(response.RolledBackPatchNumbers) != 0 {
t.Fatalf("expected no rolled back numbers, got %+v", response.RolledBackPatchNumbers)
}
}
type patchCheckFixture struct {
handler *DeviceHandler
db db.Store
storage storage.Store
appID uuid.UUID
releaseID int
channelID int
}
func newPatchCheckFixture(t *testing.T) *patchCheckFixture {
t.Helper()
database, err := db.NewSqliteStore(t.TempDir() + "/server.db")
if err != nil {
t.Fatal(err)
}
t.Cleanup(database.Close)
ctx := context.Background()
orgID, err := database.CreateOrganization(ctx, "test org", "team")
if err != nil {
t.Fatal(err)
}
app, err := database.CreateApp(ctx, orgID, "license test")
if err != nil {
t.Fatal(err)
}
channel, err := database.CreateChannel(ctx, app.ID, "stable")
if err != nil {
t.Fatal(err)
}
release, err := database.CreateRelease(ctx, app.ID, testReleaseVersion, "flutter-rev", nil, nil)
if err != nil {
t.Fatal(err)
}
localStorage, err := storage.NewLocalStore(t.TempDir(), "http://updates.test", "")
if err != nil {
t.Fatal(err)
}
return &patchCheckFixture{
handler: &DeviceHandler{
DB: database,
Storage: localStorage,
},
db: database,
storage: localStorage,
appID: app.ID,
releaseID: release.ID,
channelID: channel.ID,
}
}
func (f *patchCheckFixture) createPatch(t *testing.T, number int, offlineExpiresAt time.Time) {
t.Helper()
ctx := context.Background()
patch, err := f.db.CreatePatch(ctx, f.releaseID, number, nil)
if err != nil {
t.Fatal(err)
}
storageKey := storage.GeneratePatchStorageKey(f.appID.String(), f.releaseID, number, testPlatform, testArch, "dlc.vmcode")
patchBytes := []byte(fmt.Sprintf("patch-%d-plaintext", number))
hash := sha256Hex(patchBytes)
if err := f.storage.UploadObject(ctx, storageKey, bytes.NewReader(patchBytes), int64(len(patchBytes)), "application/octet-stream", true); err != nil {
t.Fatal(err)
}
expires := offlineExpiresAt.UTC()
if _, err := f.db.CreatePatchArtifact(ctx, patch.ID, testArch, testPlatform, hash, storageKey, 128, nil, nil, &expires); err != nil {
t.Fatal(err)
}
if err := f.db.PromotePatch(ctx, patch.ID, f.channelID); err != nil {
t.Fatal(err)
}
}
func TestPatchCheckReturnsPlaintextPatchWhenEncryptedDeliveryNotAccepted(t *testing.T) {
fixture := newPatchCheckFixture(t)
fixture.handler.PatchDelivery = PatchDeliveryConfig{
EncryptionMode: PatchDeliveryEncryptionPerDeviceAESGCM,
AESSecret: []byte("server-secret"),
CacheEncryptedPatches: true,
}
fixture.createPatch(t, 1, time.Now().Add(time.Hour))
clientID := "device-1"
response := fixture.patchCheck(t, &models.PatchCheckRequest{
ReleaseVersion: testReleaseVersion,
Platform: testPlatform,
Arch: testArch,
AppID: fixture.appID.String(),
Channel: "stable",
ClientID: &clientID,
})
if !response.PatchAvailable || response.Patch == nil {
t.Fatalf("expected patch, got %+v", response)
}
if response.Patch.Encryption != nil {
t.Fatalf("did not expect encryption metadata without opt-in: %+v", response.Patch.Encryption)
}
if strings.Contains(response.Patch.DownloadURL, "/encrypted/") {
t.Fatalf("expected plaintext URL, got %s", response.Patch.DownloadURL)
}
}
func TestPatchCheckEncryptsPatchPerDeviceAndCaches(t *testing.T) {
fixture := newPatchCheckFixture(t)
secret := []byte("server-secret")
fixture.handler.PatchDelivery = PatchDeliveryConfig{
EncryptionMode: PatchDeliveryEncryptionPerDeviceAESGCM,
AESSecret: secret,
CacheEncryptedPatches: true,
}
fixture.createPatch(t, 1, time.Now().Add(time.Hour))
clientID := "device-1"
request := &models.PatchCheckRequest{
ReleaseVersion: testReleaseVersion,
Platform: testPlatform,
Arch: testArch,
AppID: fixture.appID.String(),
Channel: "stable",
ClientID: &clientID,
AcceptEncryptedPatch: true,
}
first := fixture.patchCheck(t, request)
second := fixture.patchCheck(t, request)
if !first.PatchAvailable || first.Patch == nil || first.Patch.Encryption == nil {
t.Fatalf("expected encrypted patch, got %+v", first)
}
if first.Patch.Encryption.Algorithm != "AES-256-GCM" {
t.Fatalf("unexpected algorithm: %+v", first.Patch.Encryption)
}
if first.Patch.Encryption.CacheKey != second.Patch.Encryption.CacheKey {
t.Fatalf("expected cached object reuse, got %q then %q", first.Patch.Encryption.CacheKey, second.Patch.Encryption.CacheKey)
}
if first.Patch.Encryption.EncryptedHash != second.Patch.Encryption.EncryptedHash {
t.Fatalf("expected stable encrypted hash, got %q then %q", first.Patch.Encryption.EncryptedHash, second.Patch.Encryption.EncryptedHash)
}
if !strings.Contains(first.Patch.DownloadURL, "/encrypted/") {
t.Fatalf("expected encrypted download URL, got %s", first.Patch.DownloadURL)
}
ciphertext := fixture.readObject(t, first.Patch.Encryption.CacheKey)
if string(ciphertext) == "patch-1-plaintext" {
t.Fatal("encrypted object should not equal plaintext patch bytes")
}
plaintext := decryptPatchForTest(t, secret, first.Patch.Encryption, ciphertext)
if string(plaintext) != "patch-1-plaintext" {
t.Fatalf("unexpected decrypted patch: %q", plaintext)
}
if _, err := decryptPatchForTestWithError([]byte("wrong-secret"), first.Patch.Encryption, ciphertext); err == nil {
t.Fatal("expected wrong key to fail decrypting encrypted patch")
}
}
func TestPatchCheckDerivesDifferentEncryptedPatchForDifferentDevices(t *testing.T) {
fixture := newPatchCheckFixture(t)
fixture.handler.PatchDelivery = PatchDeliveryConfig{
EncryptionMode: PatchDeliveryEncryptionPerDeviceAESGCM,
AESSecret: []byte("server-secret"),
CacheEncryptedPatches: true,
}
fixture.createPatch(t, 1, time.Now().Add(time.Hour))
clientA := "device-a"
clientB := "device-b"
responseA := fixture.patchCheck(t, &models.PatchCheckRequest{
ReleaseVersion: testReleaseVersion,
Platform: testPlatform,
Arch: testArch,
AppID: fixture.appID.String(),
Channel: "stable",
ClientID: &clientA,
AcceptEncryptedPatch: true,
})
responseB := fixture.patchCheck(t, &models.PatchCheckRequest{
ReleaseVersion: testReleaseVersion,
Platform: testPlatform,
Arch: testArch,
AppID: fixture.appID.String(),
Channel: "stable",
ClientID: &clientB,
AcceptEncryptedPatch: true,
})
if responseA.Patch.Encryption.CacheKey == responseB.Patch.Encryption.CacheKey {
t.Fatalf("expected different cache keys, got %s", responseA.Patch.Encryption.CacheKey)
}
if responseA.Patch.Encryption.EncryptedHash == responseB.Patch.Encryption.EncryptedHash {
t.Fatalf("expected different encrypted hashes, got %s", responseA.Patch.Encryption.EncryptedHash)
}
}
func (f *patchCheckFixture) patchCheck(t *testing.T, request *models.PatchCheckRequest) models.PatchCheckResponse {
t.Helper()
body, err := json.Marshal(request)
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/patches/check", bytes.NewReader(body))
recorder := httptest.NewRecorder()
f.handler.PatchCheck(recorder, req)
if recorder.Code != http.StatusOK {
t.Fatalf("unexpected status %d: %s", recorder.Code, recorder.Body.String())
}
var response models.PatchCheckResponse
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
t.Fatal(err)
}
return response
}
func containsInt(values []int, target int) bool {
for _, value := range values {
if value == target {
return true
}
}
return false
}
func (f *patchCheckFixture) readObject(t *testing.T, objectKey string) []byte {
t.Helper()
reader, err := f.storage.GetObject(context.Background(), objectKey, true)
if err != nil {
t.Fatal(err)
}
defer reader.Close()
bytes, err := io.ReadAll(reader)
if err != nil {
t.Fatal(err)
}
return bytes
}
func decryptPatchForTest(t *testing.T, secret []byte, metadata *models.PatchEncryptionMetadata, ciphertext []byte) []byte {
t.Helper()
plaintext, err := decryptPatchForTestWithError(secret, metadata, ciphertext)
if err != nil {
t.Fatal(err)
}
return plaintext
}
func decryptPatchForTestWithError(secret []byte, metadata *models.PatchEncryptionMetadata, ciphertext []byte) ([]byte, error) {
aadBytes, err := base64.RawURLEncoding.DecodeString(metadata.AAD)
if err != nil {
return nil, err
}
nonce, err := base64.RawURLEncoding.DecodeString(metadata.Nonce)
if err != nil {
return nil, err
}
key := derivePatchDeliveryKey(secret, string(aadBytes))
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
return gcm.Open(nil, nonce, ciphertext, aadBytes)
}