774954fce7
- 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.
372 lines
12 KiB
Go
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)
|
|
}
|