From 2990c0ae8b2141ec3049458c16b2ff7eb1000fa6 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Tue, 13 Oct 2026 15:56:51 +0530 Subject: [PATCH 1/9] feat(cli): APNs key setups for init apns The appwrite (native Go), expo (apple-utils) and fastlane (spaceship) setups behind one apns.Adapter: each signs in to the Apple Developer portal, creates an APNs key, and checks or registers the App ID with Push Notifications. --- src/SDK/Language/CLI.php | 105 ++ templates/cli/internal/apns/apns.go | 300 ++++++ templates/cli/internal/apns/apns_test.go | 201 ++++ .../cli/internal/apns/apnstest/apnstest.go | 102 ++ .../cli/internal/apns/appwrite/adapter.go | 278 ++++++ .../internal/apns/appwrite/adapter_test.go | 914 ++++++++++++++++++ templates/cli/internal/apns/appwrite/appid.go | 268 +++++ templates/cli/internal/apns/appwrite/auth.go | 510 ++++++++++ templates/cli/internal/apns/appwrite/jar.go | 129 +++ .../cli/internal/apns/appwrite/portal.go | 253 +++++ templates/cli/internal/apns/appwrite/srp.go | 184 ++++ .../cli/internal/apns/appwrite/srp_test.go | 129 +++ templates/cli/internal/apns/expo/adapter.go | 420 ++++++++ .../cli/internal/apns/expo/adapter_test.go | 335 +++++++ .../cli/internal/apns/expo/helper/helper.js | 163 ++++ .../apns/expo/helper/package-lock.json | 25 + .../internal/apns/expo/helper/package.json | 11 + templates/cli/internal/apns/expo/runtime.go | 278 ++++++ .../cli/internal/apns/expo/runtime_test.go | 237 +++++ .../cli/internal/apns/fastlane/adapter.go | 489 ++++++++++ .../internal/apns/fastlane/adapter_test.go | 542 +++++++++++ .../internal/apns/fastlane/helper/Fastfile | 114 +++ 22 files changed, 5987 insertions(+) create mode 100644 templates/cli/internal/apns/apns.go create mode 100644 templates/cli/internal/apns/apns_test.go create mode 100644 templates/cli/internal/apns/apnstest/apnstest.go create mode 100644 templates/cli/internal/apns/appwrite/adapter.go create mode 100644 templates/cli/internal/apns/appwrite/adapter_test.go create mode 100644 templates/cli/internal/apns/appwrite/appid.go create mode 100644 templates/cli/internal/apns/appwrite/auth.go create mode 100644 templates/cli/internal/apns/appwrite/jar.go create mode 100644 templates/cli/internal/apns/appwrite/portal.go create mode 100644 templates/cli/internal/apns/appwrite/srp.go create mode 100644 templates/cli/internal/apns/appwrite/srp_test.go create mode 100644 templates/cli/internal/apns/expo/adapter.go create mode 100644 templates/cli/internal/apns/expo/adapter_test.go create mode 100644 templates/cli/internal/apns/expo/helper/helper.js create mode 100644 templates/cli/internal/apns/expo/helper/package-lock.json create mode 100644 templates/cli/internal/apns/expo/helper/package.json create mode 100644 templates/cli/internal/apns/expo/runtime.go create mode 100644 templates/cli/internal/apns/expo/runtime_test.go create mode 100644 templates/cli/internal/apns/fastlane/adapter.go create mode 100644 templates/cli/internal/apns/fastlane/adapter_test.go create mode 100644 templates/cli/internal/apns/fastlane/helper/Fastfile diff --git a/src/SDK/Language/CLI.php b/src/SDK/Language/CLI.php index 8de2d0b2a1..b6251f8877 100644 --- a/src/SDK/Language/CLI.php +++ b/src/SDK/Language/CLI.php @@ -1126,6 +1126,111 @@ public function getFiles(): array 'destination' => 'internal/cmd/initskill.go', 'template' => 'cli/internal/cmd/initskill.go', ], + [ + 'scope' => 'copy', + 'destination' => 'internal/apns/apns.go', + 'template' => 'cli/internal/apns/apns.go', + ], + [ + 'scope' => 'default', + 'destination' => 'internal/apns/apns_test.go', + 'template' => 'cli/internal/apns/apns_test.go', + ], + [ + 'scope' => 'default', + 'destination' => 'internal/apns/apnstest/apnstest.go', + 'template' => 'cli/internal/apns/apnstest/apnstest.go', + ], + [ + 'scope' => 'default', + 'destination' => 'internal/apns/appwrite/adapter.go', + 'template' => 'cli/internal/apns/appwrite/adapter.go', + ], + [ + 'scope' => 'default', + 'destination' => 'internal/apns/appwrite/adapter_test.go', + 'template' => 'cli/internal/apns/appwrite/adapter_test.go', + ], + [ + 'scope' => 'default', + 'destination' => 'internal/apns/appwrite/appid.go', + 'template' => 'cli/internal/apns/appwrite/appid.go', + ], + [ + 'scope' => 'default', + 'destination' => 'internal/apns/appwrite/auth.go', + 'template' => 'cli/internal/apns/appwrite/auth.go', + ], + [ + 'scope' => 'copy', + 'destination' => 'internal/apns/appwrite/jar.go', + 'template' => 'cli/internal/apns/appwrite/jar.go', + ], + [ + 'scope' => 'default', + 'destination' => 'internal/apns/appwrite/portal.go', + 'template' => 'cli/internal/apns/appwrite/portal.go', + ], + [ + 'scope' => 'copy', + 'destination' => 'internal/apns/appwrite/srp.go', + 'template' => 'cli/internal/apns/appwrite/srp.go', + ], + [ + 'scope' => 'copy', + 'destination' => 'internal/apns/appwrite/srp_test.go', + 'template' => 'cli/internal/apns/appwrite/srp_test.go', + ], + [ + 'scope' => 'default', + 'destination' => 'internal/apns/expo/adapter.go', + 'template' => 'cli/internal/apns/expo/adapter.go', + ], + [ + 'scope' => 'default', + 'destination' => 'internal/apns/expo/adapter_test.go', + 'template' => 'cli/internal/apns/expo/adapter_test.go', + ], + [ + 'scope' => 'copy', + 'destination' => 'internal/apns/expo/helper/helper.js', + 'template' => 'cli/internal/apns/expo/helper/helper.js', + ], + [ + 'scope' => 'copy', + 'destination' => 'internal/apns/expo/helper/package.json', + 'template' => 'cli/internal/apns/expo/helper/package.json', + ], + [ + 'scope' => 'copy', + 'destination' => 'internal/apns/expo/helper/package-lock.json', + 'template' => 'cli/internal/apns/expo/helper/package-lock.json', + ], + [ + 'scope' => 'copy', + 'destination' => 'internal/apns/expo/runtime.go', + 'template' => 'cli/internal/apns/expo/runtime.go', + ], + [ + 'scope' => 'default', + 'destination' => 'internal/apns/expo/runtime_test.go', + 'template' => 'cli/internal/apns/expo/runtime_test.go', + ], + [ + 'scope' => 'default', + 'destination' => 'internal/apns/fastlane/adapter.go', + 'template' => 'cli/internal/apns/fastlane/adapter.go', + ], + [ + 'scope' => 'default', + 'destination' => 'internal/apns/fastlane/adapter_test.go', + 'template' => 'cli/internal/apns/fastlane/adapter_test.go', + ], + [ + 'scope' => 'copy', + 'destination' => 'internal/apns/fastlane/helper/Fastfile', + 'template' => 'cli/internal/apns/fastlane/helper/Fastfile', + ], [ 'scope' => 'default', 'destination' => 'internal/cmd/pull.go', diff --git a/templates/cli/internal/apns/apns.go b/templates/cli/internal/apns/apns.go new file mode 100644 index 0000000000..5b24baf5ea --- /dev/null +++ b/templates/cli/internal/apns/apns.go @@ -0,0 +1,300 @@ +// Package apns creates Apple Push Notification service (APNs) auth keys. +// +// Apple has no public API for creating APNs keys. The tools that automate it, +// fastlane's spaceship and @expo/apple-utils, sign in to the Apple Developer +// portal as a person would and call the portal's private API, and so does +// Appwrite's own implementation. Apple can change that API without notice, so +// each implementation is an Adapter, and which one runs is a setting rather +// than a release: when Apple breaks one, people switch to another. +// +// The package imports nothing from the CLI. Prompts and logging come in +// through the Request, so it can move to its own module and repository +// unchanged. +package apns + +import ( + "context" + "errors" + "fmt" + "regexp" + "sort" + "strings" +) + +// Environment is the APNs environment a key works in. +type Environment string + +// The environments Apple offers when creating a key. +const ( + EnvironmentAll Environment = "all" + EnvironmentProduction Environment = "production" + EnvironmentSandbox Environment = "sandbox" +) + +// ParseEnvironment reads an environment name. Empty means all. +func ParseEnvironment(value string) (Environment, error) { + switch environment := Environment(strings.ToLower(strings.TrimSpace(value))); environment { + case "", EnvironmentAll: + return EnvironmentAll, nil + case EnvironmentProduction, EnvironmentSandbox: + return environment, nil + default: + return "", fmt.Errorf("unknown APNs environment %q: use production, sandbox or all", value) + } +} + +// Key is a newly created APNs key. Apple lets its contents be downloaded once. +type Key struct { + KeyID string + TeamID string + P8 string + // AppleID is the Apple ID that signed in, when the adapter knows it, so + // later steps can reuse the saved sign-in instead of asking again. + AppleID string +} + +// ExistingKey is a key already on the team. +type ExistingKey struct { + ID string + Name string + CanRevoke bool +} + +// MaxKeysError means the team already has as many APNs keys as Apple allows. +// Keys lists the team's keys when the adapter could read them. +type MaxKeysError struct { + Keys []ExistingKey +} + +func (e *MaxKeysError) Error() string { + return "the Apple team already has the maximum number of APNs keys" +} + +var ( + // ErrUnavailable means the adapter cannot run here, for example because + // the runtime it needs is missing. Nothing was sent to Apple. + ErrUnavailable = errors.New("this APNs key setup is not available here") + // ErrInvalidCredentials means Apple refused the Apple ID or password. + ErrInvalidCredentials = errors.New("Apple did not accept the Apple ID and password") + // ErrSignInNeeded means a request with SavedSignInOnly found no saved + // sign-in it could use. Nothing was asked. + ErrSignInNeeded = errors.New("signing in to Apple is needed") +) + +// Asker asks the person at the terminal. +type Asker interface { + // Ask reads one line. secret hides what is typed. + Ask(question string, secret bool) (string, error) + // Choose returns the index of the chosen option. + Choose(question string, options []string) (int, error) +} + +// Request describes the key to create. +type Request struct { + // TeamID is the Apple Developer team that owns the key. Empty lets the + // adapter use the account's only team, or ask which one. + TeamID string + Name string + Environment Environment + // AppleID and Password answer the sign-in questions when set. + AppleID string + Password string + // SessionDir is where an adapter may keep a signed-in session between + // runs, readable by the user only. + SessionDir string + // Reset signs in to Apple again instead of reusing a saved sign-in, and + // the new one replaces it. + Reset bool + // SavedSignInOnly uses a saved sign-in only: without a valid one the + // adapter returns ErrSignInNeeded instead of asking. + SavedSignInOnly bool + Asker Asker + // Log prints a line for the person running the command. + Log func(format string, args ...any) +} + +// Adapter creates APNs keys, and prepares App IDs, one way. +type Adapter interface { + // Name is how people pick the adapter, for example "appwrite". + Name() string + // CreateKey signs in, creates the key and downloads it. It returns + // ErrUnavailable when it cannot run here, ErrInvalidCredentials when + // Apple refuses the sign-in, and a *MaxKeysError when the team has no + // free key slot. + CreateKey(ctx context.Context, request Request) (Key, error) + // PrepareAppID signs in like CreateKey and makes sure the bundle ID is an + // App ID with Push Notifications: APNs refuses pushes for any other + // bundle ID (TopicDisallowed), and a key alone does not register it. It + // registers the App ID when the team has none and turns on Push + // Notifications when it is off, asking first when appID.Ask is set + // (ConfirmAppIDChange words the questions). With + // request.SavedSignInOnly and no saved sign-in it returns + // ErrSignInNeeded. + PrepareAppID(ctx context.Context, request Request, appID AppIDRequest) (AppIDResult, error) +} + +// AppIDRequest names the App ID to prepare. +type AppIDRequest struct { + BundleID string + // Ask asks before registering the App ID or turning on Push + // Notifications. Without it the changes are made directly. + Ask bool +} + +// AppIDResult is what preparing an App ID found and changed. +type AppIDResult struct { + TeamID string + // Registered and Push describe the App ID after the call. + Registered bool + Push bool + // Created and PushTurnedOn are the changes the call made. + Created bool + PushTurnedOn bool +} + +// ConfirmAppIDChange asks before an App ID change: registering the bundle ID +// (with Push Notifications) when registered is false, else turning Push +// Notifications on. Every adapter asks with these words. +func ConfirmAppIDChange(asker Asker, bundleID, teamID string, registered bool) (bool, error) { + question := fmt.Sprintf("%s is not registered as an App ID on team %s, so APNs refuses pushes for it. Register it with Push Notifications?", bundleID, teamID) + if registered { + question = fmt.Sprintf("Push Notifications is off for %s, so APNs refuses pushes for it. Turn it on?", bundleID) + } + choice, err := asker.Choose(question, []string{"Yes", "No"}) + + return choice == 0, err +} + +// AppIDName is an App ID's description in the portal, which allows only +// letters, digits and spaces. +func AppIDName(bundleID string) string { + name := strings.Map(func(r rune) rune { + if r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' { + return r + } + + return ' ' + }, bundleID) + + return strings.Join(strings.Fields(name), " ") +} + +var bundleIDPattern = regexp.MustCompile(`^[A-Za-z0-9.-]+$`) + +// CheckBundleID reports whether bundleID is shaped like an iOS bundle ID. +func CheckBundleID(bundleID string) error { + if !bundleIDPattern.MatchString(bundleID) { + return fmt.Errorf("%q is not a bundle ID: a bundle ID has letters, digits, hyphens and dots", bundleID) + } + + return nil +} + +// PrepareAppID checks the request, runs the adapter, and checks what it +// returned, so every adapter is held to the same contract. +func PrepareAppID(ctx context.Context, adapter Adapter, request Request, appID AppIDRequest) (AppIDResult, error) { + if err := CheckBundleID(appID.BundleID); err != nil { + return AppIDResult{}, err + } + if request.TeamID != "" && !teamIDPattern.MatchString(request.TeamID) { + return AppIDResult{}, fmt.Errorf("%q is not an Apple team ID: a team ID is 10 uppercase letters or digits", request.TeamID) + } + if request.Log == nil { + request.Log = func(string, ...any) {} + } + + result, err := adapter.PrepareAppID(ctx, request, appID) + if err != nil { + return result, err + } + switch { + case !teamIDPattern.MatchString(result.TeamID): + return result, fmt.Errorf("the %s APNs setup returned an invalid team ID %q", adapter.Name(), result.TeamID) + case request.TeamID != "" && result.TeamID != request.TeamID: + return result, fmt.Errorf("the %s APNs setup checked the App ID on team %s, not %s", adapter.Name(), result.TeamID, request.TeamID) + case (result.Created || result.Push) && !result.Registered, result.PushTurnedOn && !result.Push: + return result, fmt.Errorf("the %s APNs setup returned an inconsistent App ID result %+v", adapter.Name(), result) + } + + return result, nil +} + +var ( + keyIDPattern = regexp.MustCompile(`^[A-Z0-9]{10}$`) + teamIDPattern = regexp.MustCompile(`^[A-Z0-9]{10}$`) +) + +// CreateKey checks the request, runs the adapter, and checks what it returned, +// so every adapter is held to the same contract. +func CreateKey(ctx context.Context, adapter Adapter, request Request) (Key, error) { + environment, err := ParseEnvironment(string(request.Environment)) + if err != nil { + return Key{}, err + } + request.Environment = environment + if strings.TrimSpace(request.Name) == "" { + return Key{}, errors.New("an APNs key needs a name") + } + if request.TeamID != "" && !teamIDPattern.MatchString(request.TeamID) { + return Key{}, fmt.Errorf("%q is not an Apple team ID: a team ID is 10 uppercase letters or digits", request.TeamID) + } + if request.Log == nil { + request.Log = func(string, ...any) {} + } + + key, err := adapter.CreateKey(ctx, request) + if err != nil { + return Key{}, err + } + switch { + case !keyIDPattern.MatchString(key.KeyID): + return Key{}, fmt.Errorf("the %s APNs setup returned an invalid key ID %q", adapter.Name(), key.KeyID) + case !teamIDPattern.MatchString(key.TeamID): + return Key{}, fmt.Errorf("the %s APNs setup returned an invalid team ID %q", adapter.Name(), key.TeamID) + case request.TeamID != "" && key.TeamID != request.TeamID: + return Key{}, fmt.Errorf("the %s APNs setup created key %s on team %s, not %s", adapter.Name(), key.KeyID, key.TeamID, request.TeamID) + case !strings.Contains(key.P8, "PRIVATE KEY"): + return Key{}, fmt.Errorf("the %s APNs setup created key %s but returned no .p8 contents", adapter.Name(), key.KeyID) + } + + return key, nil +} + +// Registry holds the adapters a build offers, by name. +type Registry struct { + adapters map[string]Adapter +} + +// NewRegistry returns a registry holding adapters. +func NewRegistry(adapters ...Adapter) *Registry { + registry := &Registry{adapters: map[string]Adapter{}} + for _, adapter := range adapters { + registry.adapters[adapter.Name()] = adapter + } + + return registry +} + +// Names lists the registered adapters in order. +func (r *Registry) Names() []string { + names := make([]string, 0, len(r.adapters)) + for name := range r.adapters { + names = append(names, name) + } + sort.Strings(names) + + return names +} + +// Get returns the adapter called name. +func (r *Registry) Get(name string) (Adapter, error) { + name = strings.ToLower(strings.TrimSpace(name)) + if adapter, ok := r.adapters[name]; ok { + return adapter, nil + } + if len(r.adapters) == 0 { + return nil, fmt.Errorf("APNs key setup %q is not available: this build has none", name) + } + + return nil, fmt.Errorf("unknown APNs key setup %q: use one of %s", name, strings.Join(r.Names(), ", ")) +} diff --git a/templates/cli/internal/apns/apns_test.go b/templates/cli/internal/apns/apns_test.go new file mode 100644 index 0000000000..6cf18df473 --- /dev/null +++ b/templates/cli/internal/apns/apns_test.go @@ -0,0 +1,201 @@ +package apns_test + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/apns" + "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/apns/apnstest" +) + +func TestParseEnvironment(t *testing.T) { + for value, want := range map[string]apns.Environment{ + "": apns.EnvironmentAll, + "all": apns.EnvironmentAll, + "Production": apns.EnvironmentProduction, + " sandbox ": apns.EnvironmentSandbox, + } { + if got, err := apns.ParseEnvironment(value); err != nil || got != want { + t.Errorf("ParseEnvironment(%q) = %q, %v", value, got, err) + } + } + if _, err := apns.ParseEnvironment("staging"); err == nil { + t.Error("accepted staging") + } +} + +func TestCreateKeyPassesTheRequestThrough(t *testing.T) { + adapter := &apnstest.Adapter{KeyID: "NEWKEY1234", TeamID: "TEAM123456"} + key, err := apns.CreateKey(context.Background(), adapter, apns.Request{Name: "Appwrite Push", Environment: "Sandbox"}) + if err != nil { + t.Fatal(err) + } + if key.KeyID != "NEWKEY1234" || key.TeamID != "TEAM123456" { + t.Errorf("key = %+v", key) + } + if len(adapter.Requests) != 1 || adapter.Requests[0].Environment != apns.EnvironmentSandbox || adapter.Requests[0].Log == nil { + t.Errorf("requests = %+v", adapter.Requests) + } +} + +func TestCreateKeyChecksTheRequest(t *testing.T) { + adapter := &apnstest.Adapter{KeyID: "NEWKEY1234"} + for _, request := range []apns.Request{ + {Name: "Appwrite Push", Environment: "staging"}, + {Name: " "}, + {Name: "Appwrite Push", TeamID: "team"}, + } { + if _, err := apns.CreateKey(context.Background(), adapter, request); err == nil { + t.Errorf("accepted %+v", request) + } + } + if len(adapter.Requests) != 0 { + t.Errorf("ran the adapter for a bad request: %+v", adapter.Requests) + } +} + +func TestCreateKeyHoldsAdaptersToTheContract(t *testing.T) { + for _, test := range []struct { + name string + adapter apns.Adapter + want string + }{ + {"key ID", &apnstest.Adapter{KeyID: "short", TeamID: "TEAM123456"}, `invalid key ID "short"`}, + {"team ID", &apnstest.Adapter{KeyID: "NEWKEY1234"}, `invalid team ID ""`}, + {"other team", &otherTeam{}, "on team OTHER12345, not TEAM123456"}, + } { + t.Run(test.name, func(t *testing.T) { + _, err := apns.CreateKey(context.Background(), test.adapter, apns.Request{Name: "Appwrite Push", TeamID: teamFor(test.name)}) + if err == nil || !strings.Contains(err.Error(), test.want) { + t.Errorf("err = %v", err) + } + }) + } + + failing := &apnstest.Adapter{Err: apns.ErrInvalidCredentials} + if _, err := apns.CreateKey(context.Background(), failing, apns.Request{Name: "Appwrite Push"}); !errors.Is(err, apns.ErrInvalidCredentials) { + t.Errorf("err = %v", err) + } +} + +func teamFor(test string) string { + if test == "other team" { + return "TEAM123456" + } + + return "" +} + +type otherTeam struct{} + +func (otherTeam) Name() string { return "other" } + +func (otherTeam) CreateKey(context.Context, apns.Request) (apns.Key, error) { + return apns.Key{KeyID: "NEWKEY1234", TeamID: "OTHER12345", P8: apnstest.P8()}, nil +} + +func (otherTeam) PrepareAppID(context.Context, apns.Request, apns.AppIDRequest) (apns.AppIDResult, error) { + return apns.AppIDResult{TeamID: "OTHER12345", Registered: true, Push: true}, nil +} + +func TestRegistry(t *testing.T) { + empty := apns.NewRegistry() + if _, err := empty.Get("appwrite"); err == nil || !strings.Contains(err.Error(), "this build has none") { + t.Errorf("empty registry err = %v", err) + } + + registry := apns.NewRegistry(&apnstest.Adapter{AdapterName: "expo"}, &apnstest.Adapter{AdapterName: "appwrite"}) + if names := strings.Join(registry.Names(), ","); names != "appwrite,expo" { + t.Errorf("names = %s", names) + } + if adapter, err := registry.Get(" Expo "); err != nil || adapter.Name() != "expo" { + t.Errorf("Get(expo) = %v, %v", adapter, err) + } + if _, err := registry.Get("fastlane"); err == nil || err.Error() != `unknown APNs key setup "fastlane": use one of appwrite, expo` { + t.Errorf("err = %v", err) + } +} + +func TestPrepareAppIDChecksTheRequest(t *testing.T) { + adapter := &apnstest.Adapter{TeamID: "TEAM123456"} + for _, test := range []struct { + bundleID, teamID, want string + }{ + {"", "", "is not a bundle ID"}, + {"com.example app", "", "is not a bundle ID"}, + {"com.example.app", "team", "is not an Apple team ID"}, + } { + if _, err := apns.PrepareAppID(context.Background(), adapter, apns.Request{TeamID: test.teamID}, apns.AppIDRequest{BundleID: test.bundleID}); err == nil || !strings.Contains(err.Error(), test.want) { + t.Errorf("%q, %q: err = %v", test.bundleID, test.teamID, err) + } + } + if len(adapter.AppIDRequests) != 0 { + t.Errorf("ran the adapter for a bad request: %+v", adapter.AppIDRequests) + } + + result, err := apns.PrepareAppID(context.Background(), adapter, apns.Request{}, apns.AppIDRequest{BundleID: "com.example.app", Ask: true}) + if err != nil || result != (apns.AppIDResult{TeamID: "TEAM123456", Registered: true, Push: true}) || !adapter.AppIDRequests[0].Ask { + t.Errorf("result = %+v, %v, calls %v", result, err, adapter.AppIDRequests) + } +} + +func TestPrepareAppIDHoldsAdaptersToTheContract(t *testing.T) { + for _, test := range []struct { + name string + adapter apns.Adapter + teamID string + want string + }{ + {"team ID", &apnstest.Adapter{AppIDResult: apns.AppIDResult{TeamID: "bad", Registered: true}}, "", `invalid team ID "bad"`}, + {"other team", &otherTeam{}, "TEAM123456", "on team OTHER12345, not TEAM123456"}, + {"created but not registered", &apnstest.Adapter{AppIDResult: apns.AppIDResult{TeamID: "TEAM123456", Created: true}}, "", "inconsistent"}, + {"push turned on but off", &apnstest.Adapter{AppIDResult: apns.AppIDResult{TeamID: "TEAM123456", Registered: true, PushTurnedOn: true}}, "", "inconsistent"}, + } { + t.Run(test.name, func(t *testing.T) { + _, err := apns.PrepareAppID(context.Background(), test.adapter, apns.Request{TeamID: test.teamID}, apns.AppIDRequest{BundleID: "com.example.app"}) + if err == nil || !strings.Contains(err.Error(), test.want) { + t.Errorf("err = %v", err) + } + }) + } + + needed := &apnstest.Adapter{SignInNeeded: true} + if _, err := apns.PrepareAppID(context.Background(), needed, apns.Request{SavedSignInOnly: true}, apns.AppIDRequest{BundleID: "com.example.app"}); !errors.Is(err, apns.ErrSignInNeeded) { + t.Errorf("err = %v", err) + } +} + +func TestAppIDNameKeepsLettersAndDigits(t *testing.T) { + if name := apns.AppIDName("io.appwrite.push-test_2"); name != "io appwrite push test 2" { + t.Errorf("name = %q", name) + } +} + +type choosing struct { + asked []string + choice int +} + +func (c *choosing) Ask(string, bool) (string, error) { return "", errors.New("no questions") } + +func (c *choosing) Choose(question string, options []string) (int, error) { + c.asked = append(c.asked, question+" "+strings.Join(options, "/")) + + return c.choice, nil +} + +func TestConfirmAppIDChange(t *testing.T) { + asker := &choosing{} + if yes, err := apns.ConfirmAppIDChange(asker, "com.example.app", "TEAM123456", false); !yes || err != nil { + t.Errorf("register = %v, %v", yes, err) + } + asker.choice = 1 + if yes, err := apns.ConfirmAppIDChange(asker, "com.example.app", "TEAM123456", true); yes || err != nil { + t.Errorf("turn on = %v, %v", yes, err) + } + if strings.Join(asker.asked, "|") != "com.example.app is not registered as an App ID on team TEAM123456, so APNs refuses pushes for it. Register it with Push Notifications? Yes/No|Push Notifications is off for com.example.app, so APNs refuses pushes for it. Turn it on? Yes/No" { + t.Errorf("asked %v", asker.asked) + } +} diff --git a/templates/cli/internal/apns/apnstest/apnstest.go b/templates/cli/internal/apns/apnstest/apnstest.go new file mode 100644 index 0000000000..763c166f2c --- /dev/null +++ b/templates/cli/internal/apns/apnstest/apnstest.go @@ -0,0 +1,102 @@ +// Package apnstest has a scripted APNs adapter for tests. +package apnstest + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "encoding/pem" + + "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/apns" +) + +// Adapter is a fake that records each request and returns a fresh P-256 key, +// or Err when set. +type Adapter struct { + AdapterName string + KeyID string + // TeamID is used when the request names no team, as when an account + // with one team signs in. + TeamID string + Err error + Requests []apns.Request + + // AppIDResult is what PrepareAppID returns; zero means a registered App + // ID with Push Notifications. Its team defaults like TeamID's. + AppIDResult apns.AppIDResult + AppIDErr error + // SignInNeeded makes a request limited to a saved sign-in get + // apns.ErrSignInNeeded, as when nothing is saved. + SignInNeeded bool + // AppIDRequests and AppIDSignIns record each PrepareAppID call. + AppIDRequests []apns.AppIDRequest + AppIDSignIns []apns.Request +} + +// Name returns AdapterName, or "fake". +func (a *Adapter) Name() string { + if a.AdapterName != "" { + return a.AdapterName + } + + return "fake" +} + +// CreateKey records the request and returns a key or Err. +func (a *Adapter) CreateKey(_ context.Context, request apns.Request) (apns.Key, error) { + a.Requests = append(a.Requests, request) + if a.Err != nil { + return apns.Key{}, a.Err + } + team := request.TeamID + if team == "" { + team = a.TeamID + } + + appleID := request.AppleID + if appleID == "" { + appleID = "dev@example.com" + } + + return apns.Key{KeyID: a.KeyID, TeamID: team, P8: P8(), AppleID: appleID}, nil +} + +// PrepareAppID records the call and returns AppIDResult or AppIDErr. +func (a *Adapter) PrepareAppID(_ context.Context, request apns.Request, appID apns.AppIDRequest) (apns.AppIDResult, error) { + a.AppIDRequests = append(a.AppIDRequests, appID) + a.AppIDSignIns = append(a.AppIDSignIns, request) + if request.SavedSignInOnly && a.SignInNeeded { + return apns.AppIDResult{}, apns.ErrSignInNeeded + } + if a.AppIDErr != nil { + return apns.AppIDResult{}, a.AppIDErr + } + result := a.AppIDResult + if result == (apns.AppIDResult{}) { + result = apns.AppIDResult{Registered: true, Push: true} + } + if result.TeamID == "" { + result.TeamID = request.TeamID + } + if result.TeamID == "" { + result.TeamID = a.TeamID + } + + return result, nil +} + +// P8 returns a new APNs-shaped private key in PEM form. +func P8() string { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + panic(err) + } + encoded, err := x509.MarshalPKCS8PrivateKey(key) + if err != nil { + panic(err) + } + + return string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: encoded})) +} diff --git a/templates/cli/internal/apns/appwrite/adapter.go b/templates/cli/internal/apns/appwrite/adapter.go new file mode 100644 index 0000000000..51eaef7975 --- /dev/null +++ b/templates/cli/internal/apns/appwrite/adapter.go @@ -0,0 +1,278 @@ +// Package appwrite is Appwrite's own APNs key setup, in Go with no runtime to +// install. +// +// It signs in the way the Apple Developer portal's web pages do, with the +// person's Apple ID, password and two-factor code, and calls the portal's +// private API. The sign-in follows spaceship (fastlane), which keeps up with +// Apple's changes in readable code, and the key calls follow +// @expo/apple-utils. +package appwrite + +import ( + "context" + "crypto/rand" + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/apns" +) + +// Adapter creates APNs keys with Appwrite's own sign-in. +type Adapter struct { + // For tests. Zero values mean Apple's servers and the real clock. + HTTP *http.Client + Hosts Hosts + Random io.Reader + Now func() time.Time +} + +// New returns the adapter with Apple's servers. +func New() *Adapter { + return &Adapter{} +} + +// Name is how people pick this setup. +func (a *Adapter) Name() string { + return "appwrite" +} + +// CreateKey signs in, creates a team-scoped APNs key and downloads it. The +// signed-in session is kept in request.SessionDir, when set, so later runs +// skip two-factor. +func (a *Adapter) CreateKey(ctx context.Context, request apns.Request) (apns.Key, error) { + portal, err := a.connect(ctx, request) + if err != nil { + return apns.Key{}, err + } + defer portal.saveSession() + + teamID, err := portal.chooseTeam(ctx, request.TeamID) + if err != nil { + return apns.Key{}, err + } + existing, err := portal.keys(ctx, teamID) + if err != nil { + return apns.Key{}, err + } + keyID, err := portal.createKey(ctx, teamID, request.Name, request.Environment) + if errors.Is(err, errMaxKeys) { + return apns.Key{}, &apns.MaxKeysError{Keys: existing} + } + if err != nil { + return apns.Key{}, err + } + p8, err := portal.downloadKey(ctx, teamID, keyID) + if err != nil { + return apns.Key{}, fmt.Errorf("created key %s but could not download it, and Apple allows only one download. Revoke it in the Apple Developer portal: %w", keyID, err) + } + + return apns.Key{KeyID: keyID, TeamID: teamID, P8: p8, AppleID: portal.appleID}, nil +} + +// PrepareAppID signs in, then makes sure the bundle ID is an App ID with Push +// Notifications on the team, asking first when appID.Ask is set. +func (a *Adapter) PrepareAppID(ctx context.Context, request apns.Request, appID apns.AppIDRequest) (apns.AppIDResult, error) { + portal, err := a.connect(ctx, request) + if err != nil { + return apns.AppIDResult{}, err + } + defer portal.saveSession() + + teamID, err := portal.chooseTeam(ctx, request.TeamID) + if err != nil { + return apns.AppIDResult{}, err + } + result := apns.AppIDResult{TeamID: teamID} + found, err := portal.appID(ctx, teamID, appID.BundleID) + if err != nil { + return result, err + } + if found == nil { + if appID.Ask { + register, err := apns.ConfirmAppIDChange(request.Asker, appID.BundleID, teamID, false) + if err != nil || !register { + return result, err + } + } + if found, err = portal.createAppID(ctx, teamID, appID.BundleID, apns.AppIDName(appID.BundleID)); err != nil { + return result, err + } + result.Created = true + } + result.Registered = true + if !found.Push { + // Registering it was already agreed to as "with Push Notifications". + if appID.Ask && !result.Created { + turnOn, err := apns.ConfirmAppIDChange(request.Asker, appID.BundleID, teamID, true) + if err != nil || !turnOn { + return result, err + } + } + if err := portal.enablePush(ctx, teamID, found); err != nil { + return result, err + } + result.PushTurnedOn = true + } + result.Push = found.Push + + return result, nil +} + +func sessionPath(dir string) string { + if dir == "" { + return "" + } + + return filepath.Join(dir, "session.json") +} + +// saveSession keeps the cookies for the next run. Without a session folder +// the session lives only as long as this run. +func (p *portalClient) saveSession() { + if path := sessionPath(p.dir); path != "" { + if err := p.jar.save(path); err != nil { + p.log("Could not save the Apple sign-in, so the next run asks again: %v", err) + } + } +} + +// connect returns a signed-in portal client, reusing the saved session when +// it is still valid and belongs to the Apple ID being used. +func (a *Adapter) connect(ctx context.Context, request apns.Request) (*portalClient, error) { + if request.SessionDir != "" { + if err := os.MkdirAll(request.SessionDir, 0o700); err != nil { + return nil, err + } + } + httpClient := a.HTTP + if httpClient == nil { + httpClient = &http.Client{Timeout: time.Minute} + } + // With Reset nothing is loaded, not even the cookie that marks this + // machine as trusted, so Apple asks for a two-factor code again; the new + // session replaces the saved one. + jar := newSavedJar() + if path := sessionPath(request.SessionDir); path != "" && !request.Reset { + jar.load(path) + } + withJar := *httpClient + withJar.Jar = jar + logf := request.Log + if logf == nil { + logf = func(string, ...any) {} + } + portal := &portalClient{ + http: &withJar, jar: jar, hosts: a.Hosts, dir: request.SessionDir, asker: request.Asker, log: logf, + random: a.Random, now: a.Now, + } + if portal.hosts == (Hosts{}) { + portal.hosts = DefaultHosts + } + if portal.random == nil { + portal.random = rand.Reader + } + if portal.now == nil { + portal.now = time.Now + } + + accountName := strings.TrimSpace(request.AppleID) + if session, err := portal.session(ctx); err == nil && session != nil { + if accountName == "" || strings.EqualFold(session.User.EmailAddress, accountName) { + logf("Using the saved Apple sign-in for %s.", session.User.EmailAddress) + portal.appleID = session.User.EmailAddress + + return portal, nil + } + // Another Apple ID's session: none of it applies. + jar = newSavedJar() + withJar.Jar = jar + portal.jar = jar + } + if request.SavedSignInOnly { + return nil, apns.ErrSignInNeeded + } + // An expired session's cookies stay: among them is the one that marks + // this machine as trusted, which lets Apple skip two-factor on sign-in. + if err := portal.loadWidgetKey(ctx); err != nil { + return nil, err + } + + if accountName == "" { + answer, err := request.Asker.Ask("Apple ID (email)", false) + if err != nil { + return nil, err + } + accountName = strings.TrimSpace(answer) + } + password := request.Password + if password == "" { + answer, err := request.Asker.Ask("Password for "+accountName, true) + if err != nil { + return nil, err + } + password = answer + } + + if err := portal.signIn(ctx, accountName, password); err != nil { + return nil, err + } + // Saved as soon as Apple accepts the sign-in, so a failure after this + // point does not cost another two-factor code. + portal.saveSession() + session, err := portal.session(ctx) + if err != nil { + return nil, fmt.Errorf("could not reach App Store Connect: %w", err) + } + if session == nil { + return nil, errors.New("Apple signed you in but App Store Connect refused the session. Sign in once at https://appstoreconnect.apple.com to accept any pending agreements, then try again") + } + portal.appleID = accountName + + return portal, nil +} + +// chooseTeam checks teamID against the account's teams, or picks one. +func (p *portalClient) chooseTeam(ctx context.Context, teamID string) (string, error) { + teams, err := p.teams(ctx) + if errors.Is(err, errSessionExpired) { + return "", errors.New("the Apple session expired. Run the command again to sign in") + } + if err != nil { + return "", err + } + if len(teams) == 0 { + return "", errors.New("this Apple ID belongs to no Apple Developer team. APNs keys need a paid Apple Developer Program membership") + } + if teamID != "" { + ids := make([]string, len(teams)) + for index, team := range teams { + if team.ID == teamID { + return teamID, nil + } + ids[index] = team.ID + } + + return "", fmt.Errorf("this Apple ID is not on team %s. Its teams are %s", teamID, strings.Join(ids, ", ")) + } + if len(teams) == 1 { + p.log("Using Apple team %s (%s).", teams[0].Name, teams[0].ID) + + return teams[0].ID, nil + } + labels := make([]string, len(teams)) + for index, team := range teams { + labels[index] = fmt.Sprintf("%s (%s)", team.Name, team.ID) + } + chosen, err := p.asker.Choose("Which Apple Developer team should own the key?", labels) + if err != nil { + return "", err + } + + return teams[chosen].ID, nil +} diff --git a/templates/cli/internal/apns/appwrite/adapter_test.go b/templates/cli/internal/apns/appwrite/adapter_test.go new file mode 100644 index 0000000000..27225f1c3a --- /dev/null +++ b/templates/cli/internal/apns/appwrite/adapter_test.go @@ -0,0 +1,914 @@ +package appwrite + +import ( + "bytes" + "context" + "crypto/sha1" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "math/big" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "sync" + "testing" + "time" + + "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/apns" +) +{% verbatim %} +const ( + testAppleID = "dev@example.com" + testPassword = "correct horse battery staple" + testCode = "123456" + testP8 = "-----BEGIN PRIVATE KEY-----\nMIGT\n-----END PRIVATE KEY-----" +) + +// fakeApple plays idmsa.apple.com, App Store Connect and the Developer +// portal: it runs the server side of SRP, so a wrong password or a wrong +// proof fails the way it would against Apple. +type fakeApple struct { + mu sync.Mutex + t *testing.T + requests []string + + widgetKeyGone bool + noTwoFactor bool + noTrustedDevices bool + olympusRefuses bool + // generation names the current signed-in cookie; bumping it expires + // every session handed out before. + generation int + textsSent int + // hashcashBits is the difficulty asked for; empty means 8. + hashcashBits string + teams []Team + keys []apns.ExistingKey + maxKeys bool + + salt []byte + b *big.Int + serverB []byte + clientA []byte + verifier *big.Int + created map[string]map[string]any + // appIDs are the team's App IDs by identifier. + appIDs map[string]*fakeAppID + // appIDPages counts the App ID list pages served. + appIDPages int +} + +type fakeAppID struct { + id string + push bool +} + +func newFakeApple(t *testing.T) *fakeApple { + return &fakeApple{ + t: t, + teams: []Team{{ID: "ABCDE12345", Name: "Example Inc", Type: "Company/Organization"}}, + salt: []byte("0123456789abcdef"), + created: map[string]map[string]any{}, + appIDs: map[string]*fakeAppID{}, + } +} + +func (f *fakeApple) record(r *http.Request) { + f.mu.Lock() + defer f.mu.Unlock() + f.requests = append(f.requests, r.Method+" "+r.URL.Path) +} + +func (f *fakeApple) signedIn(r *http.Request) bool { + cookie, err := r.Cookie("myacinfo") + + return err == nil && cookie.Value == f.sessionCookie().Value +} + +func (f *fakeApple) sessionCookie() *http.Cookie { + return &http.Cookie{Name: "myacinfo", Value: fmt.Sprintf("signed-in-%d", f.generation), Path: "/"} +} + +// trusted reports whether the request carries the cookie 2sv/trust handed +// out, quoted the way Apple quotes it. +func (f *fakeApple) trusted(r *http.Request) bool { + cookie, err := r.Cookie("DES123") + + return err == nil && cookie.Value == "trusted==SRVT" && cookie.Quoted +} + +func writeJSON(w http.ResponseWriter, status int, value any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(value) +} + +func (f *fakeApple) ServeHTTP(w http.ResponseWriter, r *http.Request) { + f.record(r) + body, _ := io.ReadAll(r.Body) + var params map[string]any + if strings.HasPrefix(r.Header.Get("Content-Type"), "application/x-www-form-urlencoded") { + form, _ := url.ParseQuery(string(body)) + params = map[string]any{} + for name := range form { + params[name] = form.Get(name) + } + } else { + _ = json.Unmarshal(body, ¶ms) + } + idmsa := r.Header.Get("X-Apple-Widget-Key") == "widget-key" + twoFactorSession := r.Header.Get("X-Apple-Id-Session-Id") == "session-1" && r.Header.Get("scnt") == "scnt-1" + + switch { + case r.Method == http.MethodHead && r.URL.Path == "/logout": + if len(r.Cookies()) > 0 { + f.t.Error("the widget key request sent cookies, which would sign the session out") + } + if !f.widgetKeyGone { + w.Header().Set("Location", "https://idmsa.apple.com/IDMSWebAuth/signout?widgetKey=widget-key&path=/") + } + w.WriteHeader(http.StatusFound) + + case r.URL.Path == "/olympus/v1/app/config": + w.WriteHeader(http.StatusNotFound) + + case r.URL.Path == "/appleauth/auth/signin/init" && idmsa: + f.clientA, _ = base64.StdEncoding.DecodeString(params["a"].(string)) + key, _ := passwordKey(testPassword, f.salt, 1000, "s2k") + x := new(big.Int).SetBytes(sha256Of(f.salt, sha256Of([]byte(":"), key))) + f.verifier = new(big.Int).Exp(srpG, x, srpN) + f.b = big.NewInt(987654321) + B := new(big.Int).Mul(hashPadded(srpN.Bytes(), srpG.Bytes()), f.verifier) + B.Add(B, new(big.Int).Exp(srpG, f.b, srpN)) + B.Mod(B, srpN) + f.serverB = B.Bytes() + writeJSON(w, http.StatusOK, map[string]any{ + "iteration": 1000, "salt": base64.StdEncoding.EncodeToString(f.salt), "protocol": "s2k", + "b": base64.StdEncoding.EncodeToString(f.serverB), "c": "challenge-c", + }) + + case r.Method == http.MethodGet && r.URL.Path == "/appleauth/auth/signin": + bits := f.hashcashBits + if bits == "" { + bits = "8" + } + w.Header().Set("X-Apple-HC-Bits", bits) + w.Header().Set("X-Apple-HC-Challenge", "hc-challenge") + w.WriteHeader(http.StatusOK) + + case r.URL.Path == "/appleauth/auth/signin/complete" && idmsa: + token := r.Header.Get("X-Apple-HC") + if f.hashcashBits != "" && token != "" { + f.t.Errorf("answered a %s-bit hashcash: %q", f.hashcashBits, token) + } else if f.hashcashBits == "" && (!strings.Contains(token, ":hc-challenge::") || leadingZeroBits(sha1.Sum([]byte(token))) < 8) { + f.t.Errorf("hashcash = %q", token) + } + if params["c"] != "challenge-c" || params["accountName"] != testAppleID { + f.t.Errorf("complete params = %v", params) + } + // The server's side of SRP-6a: S = (A * v^u) ^ b mod N. + A := new(big.Int).SetBytes(f.clientA) + u := hashPadded(f.clientA, f.serverB) + S := new(big.Int).Exp(f.verifier, u, srpN) + S.Mul(S, A) + S.Exp(S, f.b, srpN) + K := sha256Of(S.Bytes()) + xor := new(big.Int).Xor(hashPadded(srpN.Bytes()), hashPadded(srpG.Bytes())) + expected := sha256Of(xor.Bytes(), sha256Of([]byte(testAppleID)), f.salt, f.clientA, f.serverB, K) + m1, _ := base64.StdEncoding.DecodeString(params["m1"].(string)) + if !bytes.Equal(m1, expected) { + writeJSON(w, http.StatusUnauthorized, map[string]any{"serviceErrors": []map[string]string{{"code": "-20101", "message": "Your Apple ID or password was incorrect."}}}) + + return + } + if f.noTwoFactor || f.trusted(r) { + http.SetCookie(w, f.sessionCookie()) + w.WriteHeader(http.StatusOK) + + return + } + w.Header().Set("X-Apple-Id-Session-Id", "session-1") + w.Header().Set("scnt", "scnt-1") + writeJSON(w, http.StatusConflict, map[string]any{"authType": "hsa2"}) + + case r.Method == http.MethodGet && r.URL.Path == "/appleauth/auth" && twoFactorSession: + writeJSON(w, http.StatusOK, map[string]any{ + "trustedPhoneNumbers": []map[string]any{{"id": 1, "numberWithDialCode": "+1 (•••) •••-••12", "pushMode": "sms"}}, + "securityCode": map[string]any{"length": 6}, + "noTrustedDevices": f.noTrustedDevices, + }) + + case r.URL.Path == "/appleauth/auth/verify/trusteddevice/securitycode" && twoFactorSession: + code := params["securityCode"].(map[string]any)["code"] + if code != testCode { + writeJSON(w, http.StatusBadRequest, map[string]any{"serviceErrors": []map[string]string{{"code": "-21669", "message": "Incorrect verification code."}}}) + + return + } + http.SetCookie(w, f.sessionCookie()) + w.WriteHeader(http.StatusNoContent) + + case r.Method == http.MethodPut && r.URL.Path == "/appleauth/auth/verify/phone" && twoFactorSession: + f.textsSent++ + writeJSON(w, http.StatusOK, map[string]any{}) + + case r.URL.Path == "/appleauth/auth/verify/phone/securitycode" && twoFactorSession: + phone, _ := params["phoneNumber"].(map[string]any) + code := params["securityCode"].(map[string]any)["code"] + if phone["id"] != float64(1) || params["mode"] != "sms" || code != testCode { + writeJSON(w, http.StatusBadRequest, map[string]any{"serviceErrors": []map[string]string{{"code": "-21669", "message": "Incorrect verification code."}}}) + + return + } + http.SetCookie(w, f.sessionCookie()) + w.WriteHeader(http.StatusOK) + + case r.URL.Path == "/appleauth/auth/2sv/trust" && twoFactorSession: + http.SetCookie(w, &http.Cookie{Name: "DES123", Value: "trusted==SRVT", Quoted: true, Path: "/"}) + w.WriteHeader(http.StatusNoContent) + + case r.URL.Path == "/olympus/v1/session": + if !f.signedIn(r) || f.olympusRefuses { + w.WriteHeader(http.StatusUnauthorized) + + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "user": map[string]any{"emailAddress": testAppleID}, "provider": map[string]any{"providerId": 1, "name": "Example Inc"}, + }) + + case strings.HasPrefix(r.URL.Path, "/services-account/QH65B2/"): + if !f.signedIn(r) { + w.WriteHeader(http.StatusUnauthorized) + + return + } + f.portal(w, r, strings.TrimPrefix(r.URL.Path, "/services-account/QH65B2/"), params) + + case strings.HasPrefix(r.URL.Path, "/services-account/v1/"): + if !f.signedIn(r) { + w.WriteHeader(http.StatusUnauthorized) + + return + } + f.connectAPI(w, r, strings.TrimPrefix(r.URL.Path, "/services-account/v1/"), params) + + default: + f.t.Errorf("unexpected request %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } +} + +func (f *fakeApple) portal(w http.ResponseWriter, r *http.Request, path string, params map[string]any) { + switch path { + case "account/listTeams.action": + // The real endpoint refuses a JSON body with 415. + if r.Header.Get("Content-Type") != "" || r.ContentLength > 0 { + w.WriteHeader(http.StatusUnsupportedMediaType) + + return + } + writeJSON(w, http.StatusOK, map[string]any{"resultCode": 0, "teams": f.teams}) + case "account/auth/key/list": + // Like Apple, the key list takes a form and refuses JSON with 415. + if !strings.HasPrefix(r.Header.Get("Content-Type"), "application/x-www-form-urlencoded") { + w.WriteHeader(http.StatusUnsupportedMediaType) + + return + } + if params["teamId"] == nil || params["pageSize"] != "500" || params["sort"] != "name=asc" { + f.t.Errorf("list params = %v", params) + } + w.Header().Set("csrf", "csrf-token") + w.Header().Set("csrf_ts", "csrf-ts") + keys := []map[string]any{} + for _, key := range f.keys { + keys = append(keys, map[string]any{"keyId": key.ID, "keyName": key.Name, "canRevoke": key.CanRevoke}) + } + writeJSON(w, http.StatusOK, map[string]any{"resultCode": 0, "keys": keys}) + case "account/auth/key/v2/create": + if r.Header.Get("Content-Type") != "application/json" { + w.WriteHeader(http.StatusUnsupportedMediaType) + + return + } + if r.Header.Get("csrf") != "csrf-token" || r.Header.Get("csrf_ts") != "csrf-ts" { + f.t.Errorf("create sent csrf %q / %q", r.Header.Get("csrf"), r.Header.Get("csrf_ts")) + } + if f.maxKeys { + writeJSON(w, http.StatusOK, map[string]any{ + "resultCode": 9401, + "resultString": "You%20have%20already%20reached%20the%20maximum%20allowed%20number%20of%20team%20scoped%20Keys%20for%20this%20service%20in%20production%20and%20sandbox%20environment.", + }) + + return + } + f.created["NEWKEY1234"] = params + writeJSON(w, http.StatusOK, map[string]any{"resultCode": 0, "keys": []map[string]any{{"keyId": "NEWKEY1234", "keyName": params["name"]}}}) + case "account/auth/key/download": + if r.URL.Query().Get("keyId") != "NEWKEY1234" || r.URL.Query().Get("teamId") == "" { + f.t.Errorf("download query = %v", r.URL.Query()) + } + w.Header().Set("Content-Type", "text/plain") + _, _ = w.Write([]byte(testP8)) + default: + f.t.Errorf("unexpected portal call %s", path) + w.WriteHeader(http.StatusNotFound) + } +} + +// connectAPI plays the portal's App ID API as a web session uses it: reads +// are POSTs with X-HTTP-Method-Override: GET and the query in the body. +func (f *fakeApple) connectAPI(w http.ResponseWriter, r *http.Request, path string, params map[string]any) { + if r.Header.Get("Content-Type") != "application/vnd.api+json" || r.Header.Get("X-Requested-With") != "XMLHttpRequest" { + w.WriteHeader(http.StatusUnsupportedMediaType) + + return + } + switch { + case path == "bundleIds" && r.Method == http.MethodPost && r.Header.Get("X-HTTP-Method-Override") == http.MethodGet: + query, _ := url.ParseQuery(fmt.Sprint(params["urlEncodedQueryParams"])) + if params["teamId"] != "ABCDE12345" || query.Get("include") != "bundleIdCapabilities" { + f.t.Errorf("list App IDs params = %v", params) + } + data := []map[string]any{} + included := []map[string]any{} + // Like Apple, the filter matches by prefix, and the list comes in + // pages of limit, in ID order, with a cursor to the next. + var matching []string + for identifier := range f.appIDs { + if strings.HasPrefix(identifier, query.Get("filter[identifier]")) { + matching = append(matching, identifier) + } + } + sort.Slice(matching, func(i, j int) bool { return f.appIDs[matching[i]].id < f.appIDs[matching[j]].id }) + limit, _ := strconv.Atoi(query.Get("limit")) + offset, _ := strconv.Atoi(query.Get("cursor")) + page := matching[min(offset, len(matching)):min(offset+limit, len(matching))] + f.appIDPages++ + for _, identifier := range page { + appID := f.appIDs[identifier] + capabilities := []map[string]any{{"type": "bundleIdCapabilities", "id": appID.id + "_IN_APP_PURCHASE"}} + included = append(included, map[string]any{"type": "bundleIdCapabilities", "id": appID.id + "_IN_APP_PURCHASE", "attributes": map[string]any{"capabilityType": "IN_APP_PURCHASE"}}) + if appID.push { + capabilities = append(capabilities, map[string]any{"type": "bundleIdCapabilities", "id": appID.id + "_PUSH_NOTIFICATIONS"}) + included = append(included, map[string]any{"type": "bundleIdCapabilities", "id": appID.id + "_PUSH_NOTIFICATIONS", "attributes": map[string]any{"capabilityType": "PUSH_NOTIFICATIONS"}}) + } + data = append(data, map[string]any{ + "type": "bundleIds", + "id": appID.id, + "attributes": map[string]any{"identifier": identifier, "seedId": "ABCDE12345", "platform": "IOS"}, + "relationships": map[string]any{"bundleIdCapabilities": map[string]any{"data": capabilities}}, + }) + } + document := map[string]any{"data": data, "included": included} + if offset+limit < len(matching) { + document["links"] = map[string]any{"next": fmt.Sprintf("https://developer.apple.com/services-account/v1/bundleIds?cursor=%d&limit=%d", offset+limit, limit)} + } + writeJSON(w, http.StatusOK, document) + case path == "bundleIds" && r.Method == http.MethodPost: + data, _ := params["data"].(map[string]any) + attributes, _ := data["attributes"].(map[string]any) + identifier, _ := attributes["identifier"].(string) + name, _ := attributes["name"].(string) + if attributes["teamId"] != "ABCDE12345" || attributes["platform"] != "IOS" || identifier == "" || strings.ContainsAny(name, ".-_") { + writeJSON(w, http.StatusConflict, map[string]any{"errors": []map[string]any{{"status": "409", "detail": "An attribute value is invalid."}}}) + + return + } + appID := &fakeAppID{id: "APPID" + fmt.Sprint(len(f.appIDs)+1)} + f.appIDs[identifier] = appID + writeJSON(w, http.StatusCreated, map[string]any{"data": map[string]any{"type": "bundleIds", "id": appID.id, "attributes": attributes}}) + case strings.HasPrefix(path, "bundleIds/") && r.Method == http.MethodPatch: + encoded, _ := json.Marshal(params) + for _, appID := range f.appIDs { + if path == "bundleIds/"+appID.id && strings.Contains(string(encoded), `"id":"PUSH_NOTIFICATIONS"`) && strings.Contains(string(encoded), `"enabled":true`) && strings.Contains(string(encoded), `"teamId":"ABCDE12345"`) { + appID.push = true + writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"type": "bundleIds", "id": appID.id}}) + + return + } + } + f.t.Errorf("unexpected App ID update %s %s", path, encoded) + w.WriteHeader(http.StatusNotFound) + default: + f.t.Errorf("unexpected App ID call %s %s", r.Method, path) + w.WriteHeader(http.StatusNotFound) + } +} + +type scriptedAsker struct { + answers map[string][]string + choices map[string]int + asked []string +} + +func (s *scriptedAsker) Ask(question string, secret bool) (string, error) { + s.asked = append(s.asked, question) + for prefix, answers := range s.answers { + if strings.HasPrefix(question, prefix) && len(answers) > 0 { + s.answers[prefix] = answers[1:] + + return answers[0], nil + } + } + + return "", errors.New("no answer for " + question) +} + +func (s *scriptedAsker) Choose(question string, options []string) (int, error) { + s.asked = append(s.asked, question) + if index, ok := s.choices[question]; ok { + return index, nil + } + + return 0, errors.New("no choice for " + question) +} + +// testClient runs the adapter against the fake with a fixed request, the way +// the CLI does. +type testClient struct { + adapter *Adapter + request apns.Request +} + +func (c testClient) CreateKey(ctx context.Context, teamID, name string, environment apns.Environment) (apns.Key, error) { + request := c.request + request.TeamID, request.Name, request.Environment = teamID, name, environment + + return c.adapter.CreateKey(ctx, request) +} + +// newTestClient points the adapter at fake. credentials holds the Apple ID +// and password the CLI would pass from APPWRITE_APPLE_ID and +// APPWRITE_APPLE_PASSWORD. +func newTestClient(t *testing.T, fake *fakeApple, asker *scriptedAsker, dir string, credentials map[string]string) (testClient, *[]string) { + server := httptest.NewServer(fake) + t.Cleanup(server.Close) + logged := &[]string{} + + return testClient{ + adapter: &Adapter{ + HTTP: server.Client(), + Hosts: Hosts{Idmsa: server.URL, AppStoreConnect: server.URL, Portal: server.URL}, + Now: func() time.Time { return time.Date(2026, 10, 9, 12, 0, 0, 0, time.UTC) }, + }, + request: apns.Request{ + AppleID: credentials["APPWRITE_APPLE_ID"], + Password: credentials["APPWRITE_APPLE_PASSWORD"], + SessionDir: dir, + Asker: asker, + Log: func(format string, args ...any) { + *logged = append(*logged, strings.TrimSpace(fmt.Sprintf(format, args...))) + }, + }, + }, logged +} + +func TestCreateKeySignsInWithTwoFactorAndReusesTheSession(t *testing.T) { + fake := newFakeApple(t) + dir := t.TempDir() + asker := &scriptedAsker{answers: map[string][]string{ + "Apple ID": {testAppleID}, + "Password for": {testPassword}, + "Enter the 6-di": {"000000", testCode}, + }} + client, logged := newTestClient(t, fake, asker, dir, nil) + + key, err := client.CreateKey(context.Background(), "", "Appwrite Push", apns.EnvironmentAll) + if err != nil { + t.Fatal(err) + } + if key.KeyID != "NEWKEY1234" || key.TeamID != "ABCDE12345" || key.P8 != testP8 || key.AppleID != testAppleID { + t.Errorf("key = %+v", key) + } + created := fake.created["NEWKEY1234"] + configurations, _ := json.Marshal(created["serviceConfigurations"]) + requests, _ := json.Marshal(created["serviceConfigurationsRequests"]) + if created["teamId"] != "ABCDE12345" || created["name"] != "Appwrite Push" || created["scope"] != "team" || + string(configurations) != `{"U27F4V844T":[]}` || + string(requests) != `[{"environment":"all","identifiers":{},"isNew":true,"scope":"team","serviceId":"U27F4V844T"}]` { + t.Errorf("create params = %v", created) + } + if strings.Join(asker.asked, "|") != "Apple ID (email)|Password for "+testAppleID+"|Enter the 6-digit code shown on your Apple device (or type sms to get a text message)|Enter the 6-digit code shown on your Apple device (or type sms to get a text message)" { + t.Errorf("asked %v", asker.asked) + } + if !contains(*logged, "That code is not right. Try again.") || !contains(*logged, "Using Apple team Example Inc (ABCDE12345).") { + t.Errorf("logged %v", *logged) + } + info, err := os.Stat(filepath.Join(dir, "session.json")) + if err != nil || info.Mode().Perm() != 0o600 { + t.Fatalf("session file = %v, %v", info, err) + } + + // A second run reuses the trusted session: no questions, no sign-in. + again := &scriptedAsker{} + second, logged := newTestClient(t, fake, again, dir, nil) + fake.requests = nil + if _, err := second.CreateKey(context.Background(), "ABCDE12345", "Appwrite Push 2", apns.EnvironmentAll); err != nil { + t.Fatal(err) + } + if len(again.asked) != 0 { + t.Errorf("asked %v", again.asked) + } + for _, request := range fake.requests { + if strings.Contains(request, "/appleauth/") { + t.Errorf("signed in again: %v", fake.requests) + + break + } + } + if !contains(*logged, "Using the saved Apple sign-in for "+testAppleID+".") { + t.Errorf("logged %v", *logged) + } +} + +func TestCreateKeyUsesTheCredentialsInTheRequest(t *testing.T) { + fake := newFakeApple(t) + fake.noTwoFactor = true + asker := &scriptedAsker{} + client, logged := newTestClient(t, fake, asker, t.TempDir(), map[string]string{ + "APPWRITE_APPLE_ID": testAppleID, + "APPWRITE_APPLE_PASSWORD": testPassword, + }) + + if _, err := client.CreateKey(context.Background(), "ABCDE12345", "Appwrite Push", apns.EnvironmentAll); err != nil { + t.Fatal(err) + } + if len(asker.asked) != 0 { + t.Errorf("asked %v", asker.asked) + } + for _, line := range *logged { + if strings.Contains(line, testPassword) { + t.Errorf("logged the password: %v", *logged) + } + } +} + +func TestCreateKeyRejectsAWrongPassword(t *testing.T) { + fake := newFakeApple(t) + dir := t.TempDir() + asker := &scriptedAsker{answers: map[string][]string{"Apple ID": {testAppleID}, "Password for": {"wrong"}}} + client, _ := newTestClient(t, fake, asker, dir, nil) + + if _, err := client.CreateKey(context.Background(), "", "Appwrite Push", apns.EnvironmentAll); !errors.Is(err, apns.ErrInvalidCredentials) { + t.Fatalf("err = %v", err) + } + if _, err := os.Stat(filepath.Join(dir, "session.json")); err == nil { + t.Error("saved a session after a failed sign-in") + } +} + +func TestCreateKeyReportsAFullTeam(t *testing.T) { + fake := newFakeApple(t) + fake.noTwoFactor = true + fake.maxKeys = true + fake.keys = []apns.ExistingKey{{ID: "OLDKEY1234", Name: "Firebase", CanRevoke: true}, {ID: "OLDKEY5678", Name: "Expo", CanRevoke: true}} + client, _ := newTestClient(t, fake, &scriptedAsker{}, t.TempDir(), map[string]string{"APPWRITE_APPLE_ID": testAppleID, "APPWRITE_APPLE_PASSWORD": testPassword}) + + _, err := client.CreateKey(context.Background(), "ABCDE12345", "Appwrite Push", apns.EnvironmentAll) + var maxKeys *apns.MaxKeysError + if !errors.As(err, &maxKeys) || len(maxKeys.Keys) != 2 || maxKeys.Keys[0].ID != "OLDKEY1234" || maxKeys.Keys[1].Name != "Expo" { + t.Fatalf("err = %v", err) + } + if len(fake.created) != 0 { + t.Errorf("created %v", fake.created) + } +} + +func TestCreateKeyChoosesAmongTeams(t *testing.T) { + fake := newFakeApple(t) + fake.noTwoFactor = true + fake.teams = []Team{{ID: "AAAAA11111", Name: "Personal"}, {ID: "ABCDE12345", Name: "Example Inc"}} + env := map[string]string{"APPWRITE_APPLE_ID": testAppleID, "APPWRITE_APPLE_PASSWORD": testPassword} + asker := &scriptedAsker{choices: map[string]int{"Which Apple Developer team should own the key?": 1}} + client, _ := newTestClient(t, fake, asker, t.TempDir(), env) + + key, err := client.CreateKey(context.Background(), "", "Appwrite Push", apns.EnvironmentAll) + if err != nil || key.TeamID != "ABCDE12345" { + t.Fatalf("key = %+v, %v", key, err) + } + + other, _ := newTestClient(t, fake, &scriptedAsker{}, t.TempDir(), env) + if _, err := other.CreateKey(context.Background(), "ZZZZZ99999", "Appwrite Push", apns.EnvironmentAll); err == nil || + !strings.Contains(err.Error(), "AAAAA11111, ABCDE12345") { + t.Errorf("err = %v", err) + } +} + +func TestWidgetKeyFallsBackToTheSavedCopy(t *testing.T) { + fake := newFakeApple(t) + fake.noTwoFactor = true + fake.widgetKeyGone = true + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "widget-key"), []byte("widget-key\n"), 0o600); err != nil { + t.Fatal(err) + } + client, _ := newTestClient(t, fake, &scriptedAsker{}, dir, map[string]string{"APPWRITE_APPLE_ID": testAppleID, "APPWRITE_APPLE_PASSWORD": testPassword}) + if _, err := client.CreateKey(context.Background(), "ABCDE12345", "Appwrite Push", apns.EnvironmentAll); err != nil { + t.Fatal(err) + } + + empty, _ := newTestClient(t, fake, &scriptedAsker{}, t.TempDir(), map[string]string{"APPWRITE_APPLE_ID": testAppleID, "APPWRITE_APPLE_PASSWORD": testPassword}) + if _, err := empty.CreateKey(context.Background(), "ABCDE12345", "Appwrite Push", apns.EnvironmentAll); err == nil || + !strings.Contains(err.Error(), "sign-in key") { + t.Errorf("err = %v", err) + } +} + +func contains(lines []string, line string) bool { + for _, candidate := range lines { + if candidate == line { + return true + } + } + + return false +} + +func TestCreateKeyWithATextMessageCode(t *testing.T) { + fake := newFakeApple(t) + fake.noTrustedDevices = true + asker := &scriptedAsker{answers: map[string][]string{"Enter the 6-digit code sent to": {testCode}}} + client, _ := newTestClient(t, fake, asker, t.TempDir(), map[string]string{"APPWRITE_APPLE_ID": testAppleID, "APPWRITE_APPLE_PASSWORD": testPassword}) + + if _, err := client.CreateKey(context.Background(), "ABCDE12345", "Appwrite Push", apns.EnvironmentAll); err != nil { + t.Fatal(err) + } + if strings.Join(asker.asked, "|") != "Enter the 6-digit code sent to +1 (•••) •••-••12" { + t.Errorf("asked %v", asker.asked) + } + requests := strings.Join(fake.requests, ",") + // With no trusted devices and one phone, Apple has already texted the code. + if !strings.Contains(requests, "POST /appleauth/auth/verify/phone/securitycode") || strings.Contains(requests, "PUT /appleauth/auth/verify/phone,") { + t.Errorf("requests = %v", fake.requests) + } +} + +func TestTheSignInIsSavedAsSoonAsTheCodeIsAccepted(t *testing.T) { + fake := newFakeApple(t) + fake.olympusRefuses = true + dir := t.TempDir() + env := map[string]string{"APPWRITE_APPLE_ID": testAppleID, "APPWRITE_APPLE_PASSWORD": testPassword} + client, _ := newTestClient(t, fake, &scriptedAsker{answers: map[string][]string{"Enter the 6-di": {testCode}}}, dir, env) + + if _, err := client.CreateKey(context.Background(), "ABCDE12345", "Appwrite Push", apns.EnvironmentAll); err == nil || + !strings.Contains(err.Error(), "refused the session") { + t.Fatalf("err = %v", err) + } + if _, err := os.Stat(filepath.Join(dir, "session.json")); err != nil { + t.Fatalf("the accepted sign-in was not saved: %v", err) + } + + fake.olympusRefuses = false + again := &scriptedAsker{} + second, _ := newTestClient(t, fake, again, dir, env) + if _, err := second.CreateKey(context.Background(), "ABCDE12345", "Appwrite Push", apns.EnvironmentAll); err != nil { + t.Fatal(err) + } + if len(again.asked) != 0 { + t.Errorf("asked %v", again.asked) + } +} + +func TestAnExpiredSessionSignsInAgainWithoutACode(t *testing.T) { + fake := newFakeApple(t) + dir := t.TempDir() + env := map[string]string{"APPWRITE_APPLE_ID": testAppleID, "APPWRITE_APPLE_PASSWORD": testPassword} + first, _ := newTestClient(t, fake, &scriptedAsker{answers: map[string][]string{"Enter the 6-di": {testCode}}}, dir, env) + if _, err := first.CreateKey(context.Background(), "ABCDE12345", "Appwrite Push", apns.EnvironmentAll); err != nil { + t.Fatal(err) + } + + fake.generation++ + fake.requests = nil + again := &scriptedAsker{} + second, _ := newTestClient(t, fake, again, dir, env) + if _, err := second.CreateKey(context.Background(), "ABCDE12345", "Appwrite Push", apns.EnvironmentAll); err != nil { + t.Fatal(err) + } + if len(again.asked) != 0 { + t.Errorf("asked %v", again.asked) + } + requests := strings.Join(fake.requests, ",") + if !strings.Contains(requests, "/appleauth/auth/signin/complete") || strings.Contains(requests, "/securitycode") { + t.Errorf("requests = %v", fake.requests) + } +} + +func TestCreateKeyForOneEnvironment(t *testing.T) { + fake := newFakeApple(t) + fake.noTwoFactor = true + env := map[string]string{"APPWRITE_APPLE_ID": testAppleID, "APPWRITE_APPLE_PASSWORD": testPassword} + client, _ := newTestClient(t, fake, &scriptedAsker{}, t.TempDir(), env) + + if _, err := client.CreateKey(context.Background(), "ABCDE12345", "Appwrite Push", apns.EnvironmentSandbox); err != nil { + t.Fatal(err) + } + requests := fake.created["NEWKEY1234"]["serviceConfigurationsRequests"].([]any) + if environment := requests[0].(map[string]any)["environment"]; environment != "sandbox" { + t.Errorf("environment = %v", environment) + } + +} + +func TestChoosingATextMessageIsNotACodeAttempt(t *testing.T) { + fake := newFakeApple(t) + asker := &scriptedAsker{answers: map[string][]string{ + "Enter the 6-digit code shown": {"sms"}, + "Enter the 6-digit code sent to": {"000000", "111111", testCode}, + }} + client, _ := newTestClient(t, fake, asker, t.TempDir(), map[string]string{"APPWRITE_APPLE_ID": testAppleID, "APPWRITE_APPLE_PASSWORD": testPassword}) + + if _, err := client.CreateKey(context.Background(), "ABCDE12345", "Appwrite Push", apns.EnvironmentAll); err != nil { + t.Fatalf("the third code was not accepted: %v", err) + } + if fake.textsSent != 1 || len(asker.asked) != 4 { + t.Errorf("texts = %d, asked %v", fake.textsSent, asker.asked) + } +} + +func TestAnImpracticalHashcashIsNotAnswered(t *testing.T) { + fake := newFakeApple(t) + fake.noTwoFactor = true + fake.hashcashBits = "40" + client, _ := newTestClient(t, fake, &scriptedAsker{}, t.TempDir(), map[string]string{"APPWRITE_APPLE_ID": testAppleID, "APPWRITE_APPLE_PASSWORD": testPassword}) + + if _, err := client.CreateKey(context.Background(), "ABCDE12345", "Appwrite Push", apns.EnvironmentAll); err != nil { + t.Fatal(err) + } +} + +func TestMaxKeysMessages(t *testing.T) { + for message, full := range map[string]bool{ + "You have already reached the maximum allowed number of Keys for this service.": true, + "You have already reached the maximum allowed number of team scoped Keys for this service in production and sandbox environment.": true, + "An unexpected error occurred.": false, + } { + if got := maxKeysMessage.MatchString(message); got != full { + t.Errorf("maxKeysMessage(%q) = %v", message, got) + } + } +} + +func TestResetSignsInAgain(t *testing.T) { + fake := newFakeApple(t) + dir := t.TempDir() + env := map[string]string{"APPWRITE_APPLE_ID": testAppleID, "APPWRITE_APPLE_PASSWORD": testPassword} + first, _ := newTestClient(t, fake, &scriptedAsker{answers: map[string][]string{"Enter the 6-di": {testCode}}}, dir, env) + if _, err := first.CreateKey(context.Background(), "ABCDE12345", "Appwrite Push", apns.EnvironmentAll); err != nil { + t.Fatal(err) + } + + // The saved session and its trusted-machine cookie are ignored: Apple + // asks for the password check and a code again. + asker := &scriptedAsker{answers: map[string][]string{"Enter the 6-di": {testCode}}} + second, _ := newTestClient(t, fake, asker, dir, env) + second.request.Reset = true + fake.requests = nil + if _, err := second.CreateKey(context.Background(), "ABCDE12345", "Appwrite Push", apns.EnvironmentAll); err != nil { + t.Fatal(err) + } + requests := strings.Join(fake.requests, ",") + if !strings.Contains(requests, "/appleauth/auth/signin/complete") || !strings.Contains(requests, "/securitycode") || len(asker.asked) != 1 { + t.Errorf("asked %v, requests = %v", asker.asked, fake.requests) + } + + // The new session replaces the saved one: a normal run reuses it. + third, _ := newTestClient(t, fake, &scriptedAsker{}, dir, env) + fake.requests = nil + if _, err := third.CreateKey(context.Background(), "ABCDE12345", "Appwrite Push", apns.EnvironmentAll); err != nil { + t.Fatal(err) + } + if strings.Contains(strings.Join(fake.requests, ","), "/appleauth/") { + t.Errorf("signed in again after a reset: %v", fake.requests) + } +} + +// prepareAppID signs in with the saved session of a first run, then prepares +// com.example.app on the fake. +func prepareAppID(t *testing.T, fake *fakeApple, asker *scriptedAsker, ask bool) (apns.AppIDResult, error) { + t.Helper() + dir := t.TempDir() + env := map[string]string{"APPWRITE_APPLE_ID": testAppleID, "APPWRITE_APPLE_PASSWORD": testPassword} + first, _ := newTestClient(t, fake, &scriptedAsker{answers: map[string][]string{"Enter the 6-di": {testCode}}}, dir, env) + if _, err := first.CreateKey(context.Background(), "ABCDE12345", "Appwrite Push", apns.EnvironmentAll); err != nil { + t.Fatal(err) + } + client, _ := newTestClient(t, fake, asker, dir, env) + client.request.TeamID = "ABCDE12345" + fake.requests = nil + + return client.adapter.PrepareAppID(context.Background(), client.request, apns.AppIDRequest{BundleID: "com.example.app", Ask: ask}) +} + +const registerQuestion = "com.example.app is not registered as an App ID on team ABCDE12345, so APNs refuses pushes for it. Register it with Push Notifications?" + +func TestPrepareAppIDRegistersAMissingAppIDWithPush(t *testing.T) { + fake := newFakeApple(t) + // Only a longer identifier exists, which the portal's prefix filter also returns. + fake.appIDs["com.example.app.widget"] = &fakeAppID{id: "WIDGET", push: true} + asker := &scriptedAsker{choices: map[string]int{registerQuestion: 0}} + + result, err := prepareAppID(t, fake, asker, true) + if err != nil { + t.Fatal(err) + } + if !result.Created || !result.PushTurnedOn || !result.Registered || !result.Push || result.TeamID != "ABCDE12345" { + t.Errorf("result = %+v", result) + } + appID := fake.appIDs["com.example.app"] + if appID == nil || !appID.push { + t.Fatalf("App ID = %+v", appID) + } + // One question covers registering it with push. + if strings.Join(asker.asked, "|") != registerQuestion { + t.Errorf("asked %v", asker.asked) + } +} + +func TestPrepareAppIDTurnsOnPush(t *testing.T) { + fake := newFakeApple(t) + fake.appIDs["com.example.app"] = &fakeAppID{id: "EXISTING"} + asker := &scriptedAsker{} + + result, err := prepareAppID(t, fake, asker, false) + if err != nil { + t.Fatal(err) + } + if result.Created || !result.PushTurnedOn || !result.Push || !fake.appIDs["com.example.app"].push { + t.Errorf("result = %+v", result) + } + if len(asker.asked) != 0 || contains(fake.requests, "POST /services-account/v1/bundleIds/EXISTING") { + t.Errorf("asked %v, requests %v", asker.asked, fake.requests) + } +} + +func TestPrepareAppIDLeavesAReadyAppIDAlone(t *testing.T) { + fake := newFakeApple(t) + fake.appIDs["com.example.app"] = &fakeAppID{id: "READY", push: true} + + result, err := prepareAppID(t, fake, &scriptedAsker{}, true) + if err != nil { + t.Fatal(err) + } + if result.Created || result.PushTurnedOn || !result.Registered || !result.Push { + t.Errorf("result = %+v", result) + } + if contains(fake.requests, "PATCH /services-account/v1/bundleIds/READY") { + t.Errorf("changed a ready App ID: %v", fake.requests) + } +} + +func TestPrepareAppIDChangesNothingWhenDeclined(t *testing.T) { + fake := newFakeApple(t) + asker := &scriptedAsker{choices: map[string]int{registerQuestion: 1}} + + result, err := prepareAppID(t, fake, asker, true) + if err != nil { + t.Fatal(err) + } + if result.Registered || result.Created || len(fake.appIDs) != 0 { + t.Errorf("result = %+v, App IDs %v", result, fake.appIDs) + } +} + +func TestPrepareAppIDWithoutASavedSignInAsksNothing(t *testing.T) { + fake := newFakeApple(t) + asker := &scriptedAsker{} + client, _ := newTestClient(t, fake, asker, t.TempDir(), nil) + client.request.SavedSignInOnly = true + + _, err := client.adapter.PrepareAppID(context.Background(), client.request, apns.AppIDRequest{BundleID: "com.example.app", Ask: true}) + if !errors.Is(err, apns.ErrSignInNeeded) { + t.Fatalf("err = %v", err) + } + if len(asker.asked) != 0 || strings.Contains(strings.Join(fake.requests, ","), "/appleauth/") { + t.Errorf("asked %v, requests %v", asker.asked, fake.requests) + } +} +func TestPrepareAppIDFindsTheAppIDOnALaterPage(t *testing.T) { + fake := newFakeApple(t) + // More longer identifiers than one page, listed before the exact one. + for index := range 250 { + fake.appIDs[fmt.Sprintf("com.example.app.widget%03d", index)] = &fakeAppID{id: fmt.Sprintf("A%03d", index)} + } + fake.appIDs["com.example.app"] = &fakeAppID{id: "Z", push: true} + + result, err := prepareAppID(t, fake, &scriptedAsker{}, true) + if err != nil { + t.Fatal(err) + } + if result.Created || !result.Registered || !result.Push || fake.appIDPages != 2 { + t.Errorf("result = %+v after %d pages", result, fake.appIDPages) + } +} +{% endverbatim %} \ No newline at end of file diff --git a/templates/cli/internal/apns/appwrite/appid.go b/templates/cli/internal/apns/appwrite/appid.go new file mode 100644 index 0000000000..ee586a24f4 --- /dev/null +++ b/templates/cli/internal/apns/appwrite/appid.go @@ -0,0 +1,268 @@ +package appwrite + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "strconv" + "strings" +) +{% verbatim %} +// App IDs live behind the portal's App Store Connect style API under +// services-account/v1, which takes the same cookie session as the key calls. +// With a web session, as fastlane's spaceship uses it, reads are POSTs that +// carry X-HTTP-Method-Override: GET and the query in the body, and every +// write carries the team in its attributes. + +const ( + pushCapability = "PUSH_NOTIFICATIONS" + appIDsPageSize = 200 + // maxAppIDPages bounds the lookup should the portal keep handing out + // next pages. + maxAppIDPages = 100 +) + +// AppID is a registered App ID and whether it can receive pushes. +type AppID struct { + ID string + Identifier string + SeedID string + Push bool +} + +type jsonAPIResource struct { + Type string `json:"type"` + ID string `json:"id"` + Attributes json.RawMessage `json:"attributes"` + Relationships map[string]struct { + Data json.RawMessage `json:"data"` + } `json:"relationships"` +} + +type jsonAPIDocument struct { + Data json.RawMessage `json:"data"` + Included []jsonAPIResource `json:"included"` + // Links.Next is the next page of a list, as a URL whose query carries + // the cursor. + Links struct { + Next string `json:"next"` + } `json:"links"` + Errors []struct { + Status string `json:"status"` + Code string `json:"code"` + Title string `json:"title"` + Detail string `json:"detail"` + } `json:"errors"` +} + +// connectAPI calls services-account/v1/. A GET goes out as a POST with +// the query in the body; other methods send body as JSON:API. +func (p *portalClient) connectAPI(ctx context.Context, method, path, teamID string, query url.Values, body map[string]any) (*jsonAPIDocument, error) { + target := p.hosts.Portal + "/services-account/v1/" + path + headers := map[string]string{ + "Accept": "application/json, text/plain, */*", + "Content-Type": "application/vnd.api+json", + "X-Requested-With": "XMLHttpRequest", + } + if p.csrf != "" || p.csrfTS != "" { + headers["csrf"] = p.csrf + headers["csrf_ts"] = p.csrfTS + } + var payload any + switch method { + case http.MethodGet: + headers["X-HTTP-Method-Override"] = http.MethodGet + method = http.MethodPost + payload = map[string]any{"urlEncodedQueryParams": query.Encode(), "teamId": teamID} + default: + payload = body + } + + response, raw, err := p.request(ctx, method, target, payload, headers) + if err != nil { + return nil, fmt.Errorf("could not reach the Apple Developer portal: %w", err) + } + if token := response.Header.Get("csrf"); token != "" { + p.csrf, p.csrfTS = token, response.Header.Get("csrf_ts") + } + if response.StatusCode == http.StatusUnauthorized { + return nil, errSessionExpired + } + var document jsonAPIDocument + if len(raw) > 0 { + if err := json.Unmarshal(raw, &document); err != nil && response.StatusCode < 300 { + return nil, fmt.Errorf("the Apple Developer portal sent an unreadable response: %w", err) + } + } + if response.StatusCode >= 300 { + for _, problem := range document.Errors { + if detail := strings.TrimSpace(problem.Detail); detail != "" { + return nil, fmt.Errorf("the Apple Developer portal refused the request: %s", detail) + } + } + + return nil, fmt.Errorf("the Apple Developer portal returned HTTP %d for %s", response.StatusCode, path) + } + + return &document, nil +} + +// appID looks up identifier among the team's App IDs. It returns nil when +// the team has none with that identifier. The portal's filter also returns +// longer identifiers that start with it, so every page is read until the +// exact one turns up. +func (p *portalClient) appID(ctx context.Context, teamID, identifier string) (*AppID, error) { + query := url.Values{} + query.Set("filter[identifier]", identifier) + query.Set("include", "bundleIdCapabilities") + query.Set("limit", strconv.Itoa(appIDsPageSize)) + for page := 0; page < maxAppIDPages; page++ { + document, err := p.connectAPI(ctx, http.MethodGet, "bundleIds", teamID, query, nil) + if err != nil { + return nil, err + } + if found, err := exactAppID(document, identifier); found != nil || err != nil { + return found, err + } + if document.Links.Next == "" { + return nil, nil + } + next, err := url.Parse(document.Links.Next) + if err != nil || next.Query().Get("cursor") == "" { + return nil, fmt.Errorf("the Apple Developer portal sent an unreadable next page link %q", document.Links.Next) + } + query.Set("cursor", next.Query().Get("cursor")) + } + + return nil, fmt.Errorf("could not find %s within %d pages of App IDs", identifier, maxAppIDPages) +} + +// exactAppID returns the App ID with exactly identifier on one page of the +// list, or nil. +func exactAppID(document *jsonAPIDocument, identifier string) (*AppID, error) { + var resources []jsonAPIResource + if err := json.Unmarshal(document.Data, &resources); err != nil { + return nil, fmt.Errorf("the Apple Developer portal sent an unreadable App ID list: %w", err) + } + for _, resource := range resources { + var attributes struct { + Identifier string `json:"identifier"` + SeedID string `json:"seedId"` + } + if err := json.Unmarshal(resource.Attributes, &attributes); err != nil { + continue + } + // The filter also matches by prefix: only the exact identifier counts. + if attributes.Identifier != identifier { + continue + } + + return &AppID{ + ID: resource.ID, + Identifier: attributes.Identifier, + SeedID: attributes.SeedID, + Push: hasPush(resource, document.Included), + }, nil + } + + return nil, nil +} + +// hasPush reports whether the App ID has the Push Notifications capability +// among its included capabilities. +func hasPush(appID jsonAPIResource, included []jsonAPIResource) bool { + var linked []struct { + ID string `json:"id"` + } + if relationship, ok := appID.Relationships["bundleIdCapabilities"]; ok { + _ = json.Unmarshal(relationship.Data, &linked) + } + ids := map[string]bool{} + for _, capability := range linked { + ids[capability.ID] = true + } + for _, resource := range included { + if resource.Type != "bundleIdCapabilities" || !ids[resource.ID] { + continue + } + var attributes struct { + CapabilityType string `json:"capabilityType"` + Enabled *bool `json:"enabled"` + } + if err := json.Unmarshal(resource.Attributes, &attributes); err != nil { + continue + } + if attributes.CapabilityType == pushCapability && (attributes.Enabled == nil || *attributes.Enabled) { + return true + } + } + + return false +} + +// createAppID registers identifier as an explicit iOS App ID. +func (p *portalClient) createAppID(ctx context.Context, teamID, identifier, name string) (*AppID, error) { + document, err := p.connectAPI(ctx, http.MethodPost, "bundleIds", teamID, nil, map[string]any{ + "data": map[string]any{ + "type": "bundleIds", + "attributes": map[string]any{ + "identifier": identifier, + "name": name, + "platform": "IOS", + "seedId": teamID, + "teamId": teamID, + }, + }, + }) + if err != nil { + return nil, err + } + var resource jsonAPIResource + if err := json.Unmarshal(document.Data, &resource); err != nil || resource.ID == "" { + return nil, errors.New("the Apple Developer portal registered no App ID") + } + + return &AppID{ID: resource.ID, Identifier: identifier, SeedID: teamID}, nil +} + +// enablePush turns on the Push Notifications capability of an App ID. +func (p *portalClient) enablePush(ctx context.Context, teamID string, appID *AppID) error { + seedID := appID.SeedID + if seedID == "" { + seedID = teamID + } + _, err := p.connectAPI(ctx, http.MethodPatch, "bundleIds/"+appID.ID, teamID, nil, map[string]any{ + "data": map[string]any{ + "type": "bundleIds", + "id": appID.ID, + "attributes": map[string]any{ + "permissions": map[string]bool{"edit": true, "delete": true}, + "seedId": seedID, + "teamId": teamID, + }, + "relationships": map[string]any{ + "bundleIdCapabilities": map[string]any{ + "data": []map[string]any{{ + "type": "bundleIdCapabilities", + "attributes": map[string]any{"enabled": true, "settings": []any{}}, + "relationships": map[string]any{ + "capability": map[string]any{ + "data": map[string]string{"type": "capabilities", "id": pushCapability}, + }, + }, + }}, + }, + }, + }, + }) + if err != nil { + return err + } + appID.Push = true + + return nil +} +{% endverbatim %} \ No newline at end of file diff --git a/templates/cli/internal/apns/appwrite/auth.go b/templates/cli/internal/apns/appwrite/auth.go new file mode 100644 index 0000000000..dbd974f07a --- /dev/null +++ b/templates/cli/internal/apns/appwrite/auth.go @@ -0,0 +1,510 @@ +package appwrite + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/apns" +) + +// The sign-in follows spaceship (fastlane), which keeps up with Apple's +// changes in readable code: the widget key from the sign out redirect, SRP +// against idmsa.apple.com, hashcash, then two-factor and a trusted session. + +// Hosts are the Apple servers the client talks to. Tests point them at a fake. +type Hosts struct { + Idmsa string + AppStoreConnect string + Portal string +} + +// DefaultHosts are Apple's production servers. +var DefaultHosts = Hosts{ + Idmsa: "https://idmsa.apple.com", + AppStoreConnect: "https://appstoreconnect.apple.com", + Portal: "https://developer.apple.com", +} + +type portalClient struct { + http *http.Client + jar *savedJar + hosts Hosts + dir string + asker apns.Asker + log func(string, ...any) + random io.Reader + now func() time.Time + + // appleID is the Apple ID of the signed-in session. + appleID string + + widgetKey string + sessionID string + scnt string + csrf string + csrfTS string +} + +type olympusSession struct { + User struct { + EmailAddress string `json:"emailAddress"` + } `json:"user"` + Provider *struct { + ProviderID int64 `json:"providerId"` + Name string `json:"name"` + } `json:"provider"` +} + +type serviceErrors struct { + ServiceErrors []struct { + Code string `json:"code"` + Title string `json:"title"` + Message string `json:"message"` + } `json:"serviceErrors"` +} + +func (e serviceErrors) message() string { + for _, failure := range e.ServiceErrors { + if failure.Message != "" { + return failure.Message + } + if failure.Title != "" { + return failure.Title + } + } + + return "" +} + +func (e serviceErrors) has(code string) bool { + for _, failure := range e.ServiceErrors { + if failure.Code == code { + return true + } + } + + return false +} + +// request sends one request and reads the whole response. +func (p *portalClient) request(ctx context.Context, method, target string, body any, headers map[string]string) (*http.Response, []byte, error) { + var reader io.Reader + contentType := "" + switch value := body.(type) { + case nil: + case url.Values: + reader = strings.NewReader(value.Encode()) + contentType = "application/x-www-form-urlencoded" + default: + encoded, err := json.Marshal(body) + if err != nil { + return nil, nil, err + } + reader = bytes.NewReader(encoded) + contentType = "application/json" + } + request, err := http.NewRequestWithContext(ctx, method, target, reader) + if err != nil { + return nil, nil, err + } + request.Header.Set("User-Agent", "Appwrite CLI") + if contentType != "" { + request.Header.Set("Content-Type", contentType) + } + for name, value := range headers { + request.Header.Set(name, value) + } + response, err := p.http.Do(request) + if err != nil { + return nil, nil, err + } + defer response.Body.Close() + payload, err := io.ReadAll(response.Body) + if err != nil { + return nil, nil, err + } + + return response, payload, nil +} + +// idmsaHeaders are sent with every sign-in request. After Apple asks for +// two-factor, they also carry the session it handed out. +func (p *portalClient) idmsaHeaders() map[string]string { + headers := map[string]string{ + "Accept": "application/json, text/javascript", + "X-Requested-With": "XMLHttpRequest", + "X-Apple-Widget-Key": p.widgetKey, + } + if p.sessionID != "" { + headers["Accept"] = "application/json" + headers["X-Apple-Id-Session-Id"] = p.sessionID + headers["scnt"] = p.scnt + } + + return headers +} + +// session asks App Store Connect who is signed in. It returns nil when the +// cookies do not hold a usable session. +func (p *portalClient) session(ctx context.Context) (*olympusSession, error) { + response, payload, err := p.request(ctx, http.MethodGet, p.hosts.AppStoreConnect+"/olympus/v1/session", nil, + map[string]string{"Accept": "application/json"}) + if err != nil { + return nil, err + } + if response.StatusCode == http.StatusUnauthorized || response.StatusCode == http.StatusForbidden { + return nil, nil + } + var session olympusSession + if response.StatusCode != http.StatusOK || json.Unmarshal(payload, &session) != nil || session.Provider == nil { + return nil, nil + } + + return &session, nil +} + +// loadWidgetKey reads the key App Store Connect's web app signs in with from +// the sign out redirect, then from the copy saved by an earlier run, then from +// the endpoint Apple used to document (removed in September 2026). +func (p *portalClient) loadWidgetKey(ctx context.Context) error { + // Without a session folder there is nowhere to keep a copy. + cache := "" + if p.dir != "" { + cache = filepath.Join(p.dir, "widget-key") + } + if key := p.widgetKeyFromSignOut(ctx); key != "" { + p.widgetKey = key + p.cacheWidgetKey(cache) + + return nil + } + if cached, err := os.ReadFile(cache); cache != "" && err == nil && len(bytes.TrimSpace(cached)) > 0 { + p.widgetKey = string(bytes.TrimSpace(cached)) + + return nil + } + + response, payload, err := p.request(ctx, http.MethodGet, + p.hosts.AppStoreConnect+"/olympus/v1/app/config?hostname=itunesconnect.apple.com", nil, + map[string]string{"Accept": "application/json"}) + if err != nil { + return fmt.Errorf("could not reach App Store Connect: %w", err) + } + var config struct { + AuthServiceKey string `json:"authServiceKey"` + } + if response.StatusCode != http.StatusOK || json.Unmarshal(payload, &config) != nil || config.AuthServiceKey == "" { + return fmt.Errorf("could not read App Store Connect's sign-in key (HTTP %d)", response.StatusCode) + } + p.widgetKey = config.AuthServiceKey + p.cacheWidgetKey(cache) + + return nil +} + +func (p *portalClient) cacheWidgetKey(cache string) { + if cache != "" { + _ = os.WriteFile(cache, []byte(p.widgetKey), 0o600) + } +} + +// widgetKeyFromSignOut must neither send the session cookies nor follow the +// redirect: following it signs the session out. +func (p *portalClient) widgetKeyFromSignOut(ctx context.Context) string { + bare := &http.Client{ + Transport: p.http.Transport, + Timeout: p.http.Timeout, + CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, + } + request, err := http.NewRequestWithContext(ctx, http.MethodHead, p.hosts.AppStoreConnect+"/logout", nil) + if err != nil { + return "" + } + request.Header.Set("User-Agent", "Appwrite CLI") + response, err := bare.Do(request) + if err != nil { + return "" + } + response.Body.Close() + location, err := url.Parse(response.Header.Get("Location")) + if err != nil { + return "" + } + + return location.Query().Get("widgetKey") +} + +// signIn runs the SRP sign-in and, when Apple asks for it, two-factor. +func (p *portalClient) signIn(ctx context.Context, accountName, password string) error { + client, err := newSRPClient(p.random) + if err != nil { + return err + } + response, payload, err := p.request(ctx, http.MethodPost, p.hosts.Idmsa+"/appleauth/auth/signin/init", map[string]any{ + "a": base64.StdEncoding.EncodeToString(client.A), + "accountName": accountName, + "protocols": []string{"s2k", "s2k_fo"}, + }, p.idmsaHeaders()) + if err != nil { + return fmt.Errorf("could not reach Apple: %w", err) + } + var challenge struct { + serviceErrors + Iteration int `json:"iteration"` + Salt string `json:"salt"` + Protocol string `json:"protocol"` + B string `json:"b"` + C string `json:"c"` + } + if err := json.Unmarshal(payload, &challenge); err != nil || response.StatusCode != http.StatusOK || challenge.B == "" { + if message := challenge.message(); message != "" { + return fmt.Errorf("Apple refused the sign-in: %s", message) + } + + return fmt.Errorf("Apple refused the sign-in (HTTP %d)", response.StatusCode) + } + salt, saltErr := base64.StdEncoding.DecodeString(challenge.Salt) + serverB, bErr := base64.StdEncoding.DecodeString(challenge.B) + if saltErr != nil || bErr != nil { + return errors.New("Apple sent an unreadable sign-in challenge") + } + key, err := passwordKey(password, salt, challenge.Iteration, challenge.Protocol) + if err != nil { + return err + } + m1, m2, err := client.proofs(accountName, key, salt, serverB) + if err != nil { + return err + } + + headers := p.idmsaHeaders() + token, err := p.hashcash(ctx) + if err != nil { + return err + } + if token != "" { + headers["X-Apple-HC"] = token + } + response, payload, err = p.request(ctx, http.MethodPost, p.hosts.Idmsa+"/appleauth/auth/signin/complete?isRememberMeEnabled=false", map[string]any{ + "accountName": accountName, + "c": challenge.C, + "m1": base64.StdEncoding.EncodeToString(m1), + "m2": base64.StdEncoding.EncodeToString(m2), + "rememberMe": false, + }, headers) + if err != nil { + return fmt.Errorf("could not reach Apple: %w", err) + } + + switch response.StatusCode { + case http.StatusOK: + return nil + case http.StatusConflict: + p.sessionID = response.Header.Get("X-Apple-Id-Session-Id") + p.scnt = response.Header.Get("scnt") + + return p.twoFactor(ctx) + case http.StatusUnauthorized, http.StatusForbidden: + return apns.ErrInvalidCredentials + case http.StatusPreconditionFailed: + return errors.New("Apple needs you to accept its terms or update your account first. Sign in at https://appleid.apple.com, then try again") + default: + var failure serviceErrors + _ = json.Unmarshal(payload, &failure) + if message := failure.message(); message != "" { + return fmt.Errorf("Apple refused the sign-in: %s", message) + } + + return fmt.Errorf("Apple refused the sign-in (HTTP %d)", response.StatusCode) + } +} + +// hashcash answers the proof-of-work challenge Apple hands out on the sign-in +// page. Without one, Apple decides; spaceship carries on in that case too. +// maxHashcashBits bounds the work: Apple asks for about 11 bits, and 24 is +// some 16 million attempts, a few seconds. A harder challenge is not +// answered, as one Apple sends without a challenge is not. +const maxHashcashBits = 24 + +func (p *portalClient) hashcash(ctx context.Context) (string, error) { + response, _, err := p.request(ctx, http.MethodGet, + p.hosts.Idmsa+"/appleauth/auth/signin?widgetKey="+url.QueryEscape(p.widgetKey), nil, nil) + if err != nil { + return "", nil + } + bits, err := strconv.Atoi(response.Header.Get("X-Apple-HC-Bits")) + challenge := response.Header.Get("X-Apple-HC-Challenge") + if err != nil || challenge == "" || bits < 0 || bits > maxHashcashBits { + return "", nil + } + + return hashcash(ctx, bits, challenge, p.now()) +} + +type trustedPhone struct { + ID int64 `json:"id"` + NumberWithDialCode string `json:"numberWithDialCode"` + PushMode string `json:"pushMode"` +} + +const codeAttempts = 3 + +// twoFactor asks for the code Apple sent to the person's devices or phone, +// checks it, and marks the session trusted so later runs skip this step. +func (p *portalClient) twoFactor(ctx context.Context) error { + response, payload, err := p.request(ctx, http.MethodGet, p.hosts.Idmsa+"/appleauth/auth", nil, p.idmsaHeaders()) + if err != nil { + return fmt.Errorf("could not reach Apple: %w", err) + } + var options struct { + serviceErrors + TrustedDevices []any `json:"trustedDevices"` + TrustedPhoneNumbers []trustedPhone `json:"trustedPhoneNumbers"` + NoTrustedDevices bool `json:"noTrustedDevices"` + SecurityCode struct { + Length int `json:"length"` + } `json:"securityCode"` + } + if json.Unmarshal(payload, &options) != nil || response.StatusCode >= 300 { + return fmt.Errorf("Apple asked for two-factor authentication but did not say how (HTTP %d)", response.StatusCode) + } + if len(options.TrustedPhoneNumbers) == 0 { + if len(options.TrustedDevices) > 0 { + return errors.New("this Apple ID uses the older two-step verification. Turn on two-factor authentication at https://appleid.apple.com, then try again") + } + if message := options.message(); message != "" { + return fmt.Errorf("Apple could not send a verification code: %s", message) + } + + return errors.New("Apple asked for two-factor authentication but offered no way to receive a code") + } + length := options.SecurityCode.Length + if length == 0 { + length = 6 + } + + // A code was pushed to the trusted devices, unless there are none. With no + // devices and a single phone, Apple has already texted it. + var phone *trustedPhone + question := fmt.Sprintf("Enter the %d-digit code shown on your Apple device (or type sms to get a text message)", length) + if options.NoTrustedDevices { + if len(options.TrustedPhoneNumbers) == 1 { + phone = &options.TrustedPhoneNumbers[0] + } else if phone, err = p.textCode(ctx, options.TrustedPhoneNumbers); err != nil { + return err + } + question = fmt.Sprintf("Enter the %d-digit code sent to %s", length, phone.NumberWithDialCode) + } + + // Only codes Apple rejects count as attempts; switching to a text message + // does not. + wrong := 0 + for { + code, err := p.asker.Ask(question, false) + if err != nil { + return err + } + code = strings.TrimSpace(code) + if phone == nil && strings.EqualFold(code, "sms") { + if phone, err = p.textCode(ctx, options.TrustedPhoneNumbers); err != nil { + return err + } + question = fmt.Sprintf("Enter the %d-digit code sent to %s", length, phone.NumberWithDialCode) + + continue + } + + route := "trusteddevice" + body := map[string]any{"securityCode": map[string]any{"code": code}} + if phone != nil { + route = "phone" + body["phoneNumber"] = map[string]any{"id": phone.ID} + body["mode"] = phoneMode(phone) + } + response, payload, err := p.request(ctx, http.MethodPost, + p.hosts.Idmsa+"/appleauth/auth/verify/"+route+"/securitycode", body, p.idmsaHeaders()) + if err != nil { + return fmt.Errorf("could not reach Apple: %w", err) + } + if response.StatusCode < 300 { + break + } + var failure serviceErrors + _ = json.Unmarshal(payload, &failure) + if failure.has("-21669") || strings.Contains(strings.ToLower(failure.message()), "verification code") { + wrong++ + if wrong >= codeAttempts { + return errors.New("the verification code was wrong too many times") + } + p.log("That code is not right. Try again.") + + continue + } + if message := failure.message(); message != "" { + return fmt.Errorf("Apple refused the verification code: %s", message) + } + + return fmt.Errorf("Apple refused the verification code (HTTP %d)", response.StatusCode) + } + + // Trusting the session is what lets the next run skip two-factor. A + // failure here still leaves a working session, as spaceship notes. + _, _, _ = p.request(ctx, http.MethodGet, p.hosts.Idmsa+"/appleauth/auth/2sv/trust", nil, p.idmsaHeaders()) + + return nil +} + +func phoneMode(phone *trustedPhone) string { + if phone.PushMode != "" { + return phone.PushMode + } + + return "sms" +} + +// textCode asks which trusted phone should get the code and has Apple text it. +func (p *portalClient) textCode(ctx context.Context, phones []trustedPhone) (*trustedPhone, error) { + chosen := 0 + if len(phones) > 1 { + labels := make([]string, len(phones)) + for index, phone := range phones { + labels[index] = phone.NumberWithDialCode + } + var err error + if chosen, err = p.asker.Choose("Which phone number should get the code?", labels); err != nil { + return nil, err + } + } + phone := &phones[chosen] + response, payload, err := p.request(ctx, http.MethodPut, p.hosts.Idmsa+"/appleauth/auth/verify/phone", map[string]any{ + "phoneNumber": map[string]any{"id": phone.ID}, + "mode": phoneMode(phone), + }, p.idmsaHeaders()) + if err != nil { + return nil, fmt.Errorf("could not reach Apple: %w", err) + } + if response.StatusCode >= 300 { + var failure serviceErrors + _ = json.Unmarshal(payload, &failure) + if message := failure.message(); message != "" { + return nil, fmt.Errorf("Apple could not text the code: %s", message) + } + + return nil, fmt.Errorf("Apple could not text the code (HTTP %d)", response.StatusCode) + } + p.log("Apple sent a code to %s.", phone.NumberWithDialCode) + + return phone, nil +} diff --git a/templates/cli/internal/apns/appwrite/jar.go b/templates/cli/internal/apns/appwrite/jar.go new file mode 100644 index 0000000000..8c60dda5ac --- /dev/null +++ b/templates/cli/internal/apns/appwrite/jar.go @@ -0,0 +1,129 @@ +package appwrite + +import ( + "encoding/json" + "net/http" + "net/http/cookiejar" + "net/url" + "os" + "path/filepath" + "sync" + "time" +) + +// savedJar is a cookie jar that can be written to disk, so a signed-in and +// trusted Apple session is reused and the next run skips two-factor. +// net/http/cookiejar cannot list its cookies, so each cookie is also kept with +// the URL it came from and replayed into a fresh jar when loaded. +type savedJar struct { + mu sync.Mutex + inner *cookiejar.Jar + cookies map[string]savedCookie +} + +type savedCookie struct { + URL string `json:"url"` + Name string `json:"name"` + Value string `json:"value"` + Quoted bool `json:"quoted,omitempty"` + Domain string `json:"domain,omitempty"` + Path string `json:"path,omitempty"` + Expires time.Time `json:"expires,omitzero"` + Secure bool `json:"secure,omitempty"` + HTTPOnly bool `json:"httpOnly,omitempty"` +} + +func newSavedJar() *savedJar { + inner, _ := cookiejar.New(nil) + + return &savedJar{inner: inner, cookies: map[string]savedCookie{}} +} + +func (j *savedJar) SetCookies(u *url.URL, cookies []*http.Cookie) { + j.mu.Lock() + defer j.mu.Unlock() + j.inner.SetCookies(u, cookies) + origin := (&url.URL{Scheme: u.Scheme, Host: u.Host, Path: "/"}).String() + for _, cookie := range cookies { + domain := cookie.Domain + if domain == "" { + domain = u.Hostname() + } + id := domain + "|" + cookie.Path + "|" + cookie.Name + expires := cookie.Expires + if cookie.MaxAge > 0 { + expires = time.Now().Add(time.Duration(cookie.MaxAge) * time.Second) + } + if cookie.MaxAge < 0 || (!expires.IsZero() && expires.Before(time.Now())) { + delete(j.cookies, id) + + continue + } + j.cookies[id] = savedCookie{ + URL: origin, Name: cookie.Name, Value: cookie.Value, Quoted: cookie.Quoted, + Domain: cookie.Domain, Path: cookie.Path, Expires: expires, + Secure: cookie.Secure, HTTPOnly: cookie.HttpOnly, + } + } +} + +func (j *savedJar) Cookies(u *url.URL) []*http.Cookie { + return j.inner.Cookies(u) +} + +// load replays the cookies saved at path. A missing or unreadable file leaves +// the jar empty: the caller then signs in again. +func (j *savedJar) load(path string) { + contents, err := os.ReadFile(path) + if err != nil { + return + } + var saved []savedCookie + if json.Unmarshal(contents, &saved) != nil { + return + } + for _, cookie := range saved { + origin, err := url.Parse(cookie.URL) + if err != nil || (!cookie.Expires.IsZero() && cookie.Expires.Before(time.Now())) { + continue + } + j.SetCookies(origin, []*http.Cookie{{ + Name: cookie.Name, Value: cookie.Value, Quoted: cookie.Quoted, + Domain: cookie.Domain, Path: cookie.Path, Expires: cookie.Expires, + Secure: cookie.Secure, HttpOnly: cookie.HTTPOnly, + }}) + } +} + +// save writes the cookies to path, readable by the user only. +func (j *savedJar) save(path string) error { + j.mu.Lock() + saved := make([]savedCookie, 0, len(j.cookies)) + for _, cookie := range j.cookies { + saved = append(saved, cookie) + } + j.mu.Unlock() + + contents, err := json.Marshal(saved) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return err + } + temporary, err := os.CreateTemp(filepath.Dir(path), ".session-*") + if err != nil { + return err + } + defer os.Remove(temporary.Name()) + if _, err := temporary.Write(contents); err != nil { + temporary.Close() + + return err + } + if err := temporary.Close(); err != nil { + return err + } + + return os.Rename(temporary.Name(), path) +} diff --git a/templates/cli/internal/apns/appwrite/portal.go b/templates/cli/internal/apns/appwrite/portal.go new file mode 100644 index 0000000000..4a33de93e3 --- /dev/null +++ b/templates/cli/internal/apns/appwrite/portal.go @@ -0,0 +1,253 @@ +package appwrite + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "regexp" + "strings" + + "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/apns" +) +{% verbatim %} +// The Developer portal's private API, as the portal's own pages and +// @expo/apple-utils use it: cookie authenticated JSON under +// services-account/QH65B2, with the team in every request and CSRF tokens +// that a read hands out and a write sends back. + +const ( + apnsServiceID = "U27F4V844T" + keysPageSize = 500 +) + +// maxKeysMessage is Apple's answer when the team has no free key slot, worded +// "maximum allowed number of Keys" and, since 2026, "maximum allowed number +// of team scoped Keys for this service in production and sandbox +// environment". +var maxKeysMessage = regexp.MustCompile(`(?i)maximum allowed number of (?:[a-z ]+ )?keys`) + +var ( + errSessionExpired = errors.New("the Apple session expired") + errMaxKeys = errors.New("maximum keys") +) + +// Team is an Apple Developer team the account belongs to. +type Team struct { + ID string `json:"teamId"` + Name string `json:"name"` + Type string `json:"type"` +} + +// portalKey is a key as the portal lists it. +type portalKey struct { + ID string `json:"keyId"` + Name string `json:"keyName"` + CanRevoke bool `json:"canRevoke"` +} + +type portalResult struct { + ResultCode int `json:"resultCode"` + ResultString string `json:"resultString"` + UserString string `json:"userString"` +} + +func (r portalResult) message() string { + for _, value := range []string{r.UserString, r.ResultString} { + if decoded, err := url.PathUnescape(value); err == nil && decoded != "" { + return decoded + } + if value != "" { + return value + } + } + + return "" +} + +// bodyEncoding is how a POST to the portal sends its values. +type bodyEncoding int + +const ( + // formBody is what the portal expects, as apple-utils and spaceship send + // it: an endpoint answers a JSON body with 415. + formBody bodyEncoding = iota + // jsonBody is only for creating a key, which takes nested values. + jsonBody +) + +// portal calls one endpoint and returns the raw body after checking the +// result code. GET requests carry teamID in the query, others in the body. +func (p *portalClient) portal(ctx context.Context, method, path, teamID string, values map[string]any, encoding bodyEncoding) ([]byte, error) { + target := p.hosts.Portal + "/services-account/QH65B2/" + path + var body any + if method == http.MethodGet { + query := url.Values{} + if teamID != "" { + query.Set("teamId", teamID) + } + for name, value := range values { + query.Set(name, fmt.Sprint(value)) + } + target += "?" + query.Encode() + } else if encoding == jsonBody { + data := map[string]any{"teamId": teamID} + for name, value := range values { + data[name] = value + } + body = data + } else { + form := url.Values{} + if teamID != "" { + form.Set("teamId", teamID) + } + for name, value := range values { + form.Set(name, fmt.Sprint(value)) + } + // A call with nothing to send has no body at all. + if len(form) > 0 { + body = form + } + } + headers := map[string]string{"Accept": "application/json, text/plain, */*"} + if p.csrf != "" || p.csrfTS != "" { + headers["csrf"] = p.csrf + headers["csrf_ts"] = p.csrfTS + } + + response, payload, err := p.request(ctx, method, target, body, headers) + if err != nil { + return nil, fmt.Errorf("could not reach the Apple Developer portal: %w", err) + } + if token := response.Header.Get("csrf"); token != "" { + p.csrf, p.csrfTS = token, response.Header.Get("csrf_ts") + } + if response.StatusCode == http.StatusUnauthorized || response.StatusCode == http.StatusForbidden { + return nil, errSessionExpired + } + if response.StatusCode >= 300 { + return nil, fmt.Errorf("the Apple Developer portal returned HTTP %d for %s", response.StatusCode, path) + } + + return payload, nil +} + +// result decodes a JSON portal response, failing on a non-zero result code. +func result(payload []byte, out any) error { + var status portalResult + if err := json.Unmarshal(payload, &status); err != nil { + return fmt.Errorf("the Apple Developer portal sent an unreadable response: %w", err) + } + if status.ResultCode != 0 { + message := status.message() + if strings.Contains(message, "session has expired") { + return errSessionExpired + } + if maxKeysMessage.MatchString(message) { + return errMaxKeys + } + if message == "" { + message = fmt.Sprintf("result code %d", status.ResultCode) + } + + return fmt.Errorf("the Apple Developer portal refused the request: %s", message) + } + + return json.Unmarshal(payload, out) +} + +func (p *portalClient) teams(ctx context.Context) ([]Team, error) { + payload, err := p.portal(ctx, http.MethodPost, "account/listTeams.action", "", nil, formBody) + if err != nil { + return nil, err + } + var response struct { + Teams []Team `json:"teams"` + } + if err := result(payload, &response); err != nil { + return nil, err + } + + return response.Teams, nil +} + +// keys lists the team's keys. It also hands out the CSRF tokens that creating +// a key needs, so it always runs first. +func (p *portalClient) keys(ctx context.Context, teamID string) ([]apns.ExistingKey, error) { + var all []apns.ExistingKey + for page := 1; ; page++ { + payload, err := p.portal(ctx, http.MethodPost, "account/auth/key/list", teamID, map[string]any{ + "pageNumber": page, + "pageSize": keysPageSize, + "sort": "name=asc", + }, formBody) + if err != nil { + return nil, err + } + var response struct { + Keys []portalKey `json:"keys"` + } + if err := result(payload, &response); err != nil { + return nil, err + } + for _, key := range response.Keys { + all = append(all, apns.ExistingKey{ID: key.ID, Name: key.Name, CanRevoke: key.CanRevoke}) + } + if len(response.Keys) < keysPageSize { + return all, nil + } + } +} + +// createKey creates a team-scoped key with only APNs enabled, for the given +// environment, and returns its ID. +func (p *portalClient) createKey(ctx context.Context, teamID, name string, environment apns.Environment) (string, error) { + payload, err := p.portal(ctx, http.MethodPost, "account/auth/key/v2/create", teamID, map[string]any{ + "name": name, + "scope": "team", + "serviceConfigurations": map[string][]string{apnsServiceID: {}}, + "serviceConfigurationsRequests": []map[string]any{{ + "isNew": true, + "serviceId": apnsServiceID, + "identifiers": map[string]any{}, + "environment": string(environment), + "scope": "team", + }}, + }, jsonBody) + if err != nil { + return "", err + } + var response struct { + Keys []portalKey `json:"keys"` + } + if err := result(payload, &response); err != nil { + return "", err + } + if len(response.Keys) == 0 || response.Keys[0].ID == "" { + return "", errors.New("the Apple Developer portal created no key") + } + + return response.Keys[0].ID, nil +} + +// downloadKey returns the key's .p8 contents. Apple serves them once. +func (p *portalClient) downloadKey(ctx context.Context, teamID, keyID string) (string, error) { + payload, err := p.portal(ctx, http.MethodGet, "account/auth/key/download", teamID, map[string]any{"keyId": keyID}, formBody) + if err != nil { + return "", err + } + contents := string(payload) + if !strings.Contains(contents, "PRIVATE KEY") { + var ignored struct{} + if err := result(payload, &ignored); err != nil { + return "", err + } + + return "", errors.New("the Apple Developer portal did not send the key") + } + + return contents, nil +} +{% endverbatim %} \ No newline at end of file diff --git a/templates/cli/internal/apns/appwrite/srp.go b/templates/cli/internal/apns/appwrite/srp.go new file mode 100644 index 0000000000..91ab23d115 --- /dev/null +++ b/templates/cli/internal/apns/appwrite/srp.go @@ -0,0 +1,184 @@ +package appwrite + +import ( + "context" + "crypto/pbkdf2" + "crypto/sha1" + "crypto/sha256" + "errors" + "fmt" + "io" + "math/big" + "strconv" + "strings" + "time" +) + +// Apple signs in with SRP-6a over the 2048-bit group of RFC 5054 and SHA-256, +// so the password itself never leaves the machine. This follows +// fastlane-sirp, the SRP client spaceship uses, byte for byte. Its quirks +// matter: x leaves the username out, and A, S and the server proof are +// hashed and sent without leading zero bytes, while k and u pad their inputs +// to the length of N. + +var ( + srpN = mustHex(strings.Join([]string{ + "AC6BDB41324A9A9BF166DE5E1389582FAF72B6651987EE07FC3192943DB56050", + "A37329CBB4A099ED8193E0757767A13DD52312AB4B03310DCD7F48A9DA04FD50", + "E8083969EDB767B0CF6095179A163AB3661A05FBD5FAAAE82918A9962F0B93B8", + "55F97993EC975EEAA80D740ADBF4FF747359D041D5C33EA71D281E446B14773B", + "CA97B43A23FB801676BD207A436C6481F1D2B9078717461A5B9D32E688F87748", + "544523B524B0D57D5EA77A2775D2ECFA032CFBDBF52FB3786160279004E57AE6", + "AF874E7303CE53299CCC041C7BC308D82A5698F3A8D0C38271AE35F8E9DBFBB6", + "94B5C803D89F7AE435DE236D525F54759B65E372FCD68EF20FA7111F9E4AFF73", + }, "")) + srpG = big.NewInt(2) + srpLength = len(srpN.Bytes()) +) + +func mustHex(value string) *big.Int { + number, ok := new(big.Int).SetString(value, 16) + if !ok { + panic("invalid hex: " + value) + } + + return number +} + +func sha256Of(parts ...[]byte) []byte { + hash := sha256.New() + for _, part := range parts { + hash.Write(part) + } + + return hash.Sum(nil) +} + +// padded returns value left-padded with zeros to the length of N. +func padded(value []byte) []byte { + if len(value) >= srpLength { + return value + } + out := make([]byte, srpLength) + copy(out[srpLength-len(value):], value) + + return out +} + +// hashPadded is fastlane-sirp's H(): the hash of the padded values, as a +// number reduced modulo N. +func hashPadded(values ...[]byte) *big.Int { + parts := make([][]byte, len(values)) + for index, value := range values { + parts[index] = padded(value) + } + sum := new(big.Int).SetBytes(sha256Of(parts...)) + + return sum.Mod(sum, srpN) +} + +// passwordKey derives the value Apple's SRP uses as the password: PBKDF2 over +// the SHA-256 of the password, hex encoded first for the older s2k_fo protocol. +func passwordKey(password string, salt []byte, iterations int, protocol string) ([]byte, error) { + digest := sha256Of([]byte(password)) + switch protocol { + case "s2k": + case "s2k_fo": + digest = []byte(fmt.Sprintf("%x", digest)) + default: + return nil, fmt.Errorf("unsupported Apple sign-in protocol %q", protocol) + } + + return pbkdf2.Key(sha256.New, string(digest), salt, iterations, 32) +} + +type srpClient struct { + a *big.Int + A []byte +} + +func newSRPClient(random io.Reader) (*srpClient, error) { + secret := make([]byte, 256) + if _, err := io.ReadFull(random, secret); err != nil { + return nil, err + } + + return newSRPClientWith(new(big.Int).SetBytes(secret)), nil +} + +func newSRPClientWith(a *big.Int) *srpClient { + return &srpClient{a: a, A: new(big.Int).Exp(srpG, a, srpN).Bytes()} +} + +// proofs computes the client proof M1 and the expected server proof M2 for +// the server's salt and B, given the derived password key. +func (c *srpClient) proofs(accountName string, key, salt, serverB []byte) ([]byte, []byte, error) { + B := new(big.Int).SetBytes(serverB) + if new(big.Int).Mod(B, srpN).Sign() == 0 { + return nil, nil, errors.New("Apple sent an invalid sign-in challenge") + } + u := hashPadded(c.A, serverB) + if u.Sign() == 0 { + return nil, nil, errors.New("Apple sent an invalid sign-in challenge") + } + k := hashPadded(srpN.Bytes(), srpG.Bytes()) + x := new(big.Int).SetBytes(sha256Of(salt, sha256Of([]byte(":"), key))) + + // S = (B - k * g^x) ^ (a + u * x) mod N + base := new(big.Int).Exp(srpG, x, srpN) + base.Mul(base, k) + base.Sub(B, base) + base.Mod(base, srpN) + exponent := new(big.Int).Mul(u, x) + exponent.Add(exponent, c.a) + S := new(big.Int).Exp(base, exponent, srpN) + K := sha256Of(S.Bytes()) + + hashN := hashPadded(srpN.Bytes()) + hashG := hashPadded(srpG.Bytes()) + xor := new(big.Int).Xor(hashN, hashG) + M1 := sha256Of(xor.Bytes(), sha256Of([]byte(accountName)), salt, c.A, serverB, K) + M2 := new(big.Int).SetBytes(sha256Of(c.A, M1, K)).Bytes() + + return M1, M2, nil +} + +// hashcash answers Apple's proof-of-work challenge: the first counter whose +// "1:bits:date:challenge::counter" string has a SHA-1 starting with bits +// zero bits. The date is in UTC, as @expo/apple-utils sends it (spaceship +// uses local time, which only matches on a UTC machine). +// The search stops when ctx is done, checked every hashcashBatch attempts. +func hashcash(ctx context.Context, bits int, challenge string, now time.Time) (string, error) { + prefix := "1:" + strconv.Itoa(bits) + ":" + now.UTC().Format("20060102150405") + ":" + challenge + "::" + for counter := 0; ; counter++ { + if counter%hashcashBatch == 0 { + if err := ctx.Err(); err != nil { + return "", err + } + } + token := prefix + strconv.Itoa(counter) + if leadingZeroBits(sha1.Sum([]byte(token))) >= bits { + return token, nil + } + } +} + +const hashcashBatch = 1 << 16 + +func leadingZeroBits(sum [sha1.Size]byte) int { + count := 0 + for _, value := range sum { + if value == 0 { + count += 8 + + continue + } + for mask := byte(0x80); mask != 0 && value&mask == 0; mask >>= 1 { + count++ + } + + break + } + + return count +} diff --git a/templates/cli/internal/apns/appwrite/srp_test.go b/templates/cli/internal/apns/appwrite/srp_test.go new file mode 100644 index 0000000000..55f2fae534 --- /dev/null +++ b/templates/cli/internal/apns/appwrite/srp_test.go @@ -0,0 +1,129 @@ +package appwrite + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/hex" + "errors" + "math/big" + "testing" + "time" +) + +// The expected values were computed with fastlane-sirp and spaceship's +// hashcash, the Ruby code spaceship signs in to Apple with, for fixed inputs. +var srpVectors = []struct { + protocol, password string + iterations int + a, clientA, salt, serverB, derived, m1, m2 string +}{ + { + protocol: "s2k", + password: "correct horse battery staple", + iterations: 1000, + a: "976c0718d60c26f9f0c222b52a8e47282b714657aebb3e224000792ec3e9054c976c0718d60c26f9f0c222b52a8e47282b714657aebb3e224000792ec3e9054c976c0718d60c26f9f0c222b52a8e47282b714657aebb3e224000792ec3e9054c976c0718d60c26f9f0c222b52a8e47282b714657aebb3e224000792ec3e9054c976c0718d60c26f9f0c222b52a8e47282b714657aebb3e224000792ec3e9054c976c0718d60c26f9f0c222b52a8e47282b714657aebb3e224000792ec3e9054c976c0718d60c26f9f0c222b52a8e47282b714657aebb3e224000792ec3e9054c976c0718d60c26f9f0c222b52a8e47282b714657aebb3e224000792ec3e9054c", + clientA: "lOFBKFUcFnIqiPacVzlN2auN4I/AMJTWac7+/T1qEp2nhqIlgqfiFMgS/lLgf2YHa/88G9823FwFSNcRw/h46ujunwE/pkwZKFscpaa68psSBryNQFK1HsWrHblsQdOicXq+dmD+p70d1tx6w935hE8Htq+3iFdyqvb/nrau/qdyKJ1ueEWpnJ71r35XmHdfVQ94/qAsOp753gLCRFNdTmvFONzkqS9PRzC3Ug9F0PKeMf1kQhurzoMZRo/HqS+Pz7bTdDW5efKMg4s6uvX7t+LffwUwxuoCP/NksHQyRznffQivjnh596NJ4XCrEpvu+tE4bOBjtlSYSYI45JQmrw==", + salt: "8XJskbjIRdqLv44iq8fuiQ==", + serverB: "bmqruv0a/EiZ7xJfeLeG1ilXyXzhUJNYtDmwpbgK152y+6RctWvwBHLLnTVQlfHYzKIgMrlChKSyCl6tXjOkKz73r03bRCUL675sZDN7K3pGEL2TvrU/qdb7XBgKUWI1ctW54xynuBy1YpSYoETwrrHirVB3LDKlBdp+J3T+MfuJeR270cGJ/nDKAXtfj3qwagSrM17wI193qe4jEevZD4mvlKztv29zQ6/T9q6vTopJ524kZTzcY00t0y9r7jVFzpRXXMfg5hCzrNdKkECjFpC6/UI/xDKmyl/Uux8yG5dA5Gcxcsb0GfT7g01i3sdd619gUlceFifCpvI56NIapQ==", + derived: "adb53b51adb7d706507d630b73e0d85ec46c34ffa95eabc6bfb6893092bfe538", + m1: "T02D0QeodD+9ldyfjZQUTynd976L9gg52UBkxEFXRjE=", + m2: "bkDJQIvXJOIj1/cMlHJfnab9VrMyBSzaVNJY4kdCik0=", + }, + { + protocol: "s2k_fo", + password: "p@ss wörd", + iterations: 1001, + a: "62d6b738b14920953248eb84424d1a0a78c738c0a95e421e7f86ae320348068b62d6b738b14920953248eb84424d1a0a78c738c0a95e421e7f86ae320348068b62d6b738b14920953248eb84424d1a0a78c738c0a95e421e7f86ae320348068b62d6b738b14920953248eb84424d1a0a78c738c0a95e421e7f86ae320348068b62d6b738b14920953248eb84424d1a0a78c738c0a95e421e7f86ae320348068b62d6b738b14920953248eb84424d1a0a78c738c0a95e421e7f86ae320348068b62d6b738b14920953248eb84424d1a0a78c738c0a95e421e7f86ae320348068b62d6b738b14920953248eb84424d1a0a78c738c0a95e421e7f86ae320348068b", + clientA: "q3Y06Eo7PTQ8TvzRw5xZqRwCKcw0GTxg5+6b5a75N+Dy3rnNXSAM0/aPomhpAcHMz14FtSALPmpNXEYa3UxUUGWiLkp1RDLTzdJT2U/+X0lU6XBm5W0O8hyYe3/Na8JrQvNvsDfmahk4/w8ZKKDTSq1cbRiQHWV7wA/D5tl5jh/xPADahOe4jt3rU+COrHR3JcVDWknM0B4cL0HywA2J2/RrK/ushhR/E8Uu7wGvb7LbcFNhOmp4q6q9bLrm5zS6tbH6dXfD3V5W4bvgFOwhKKmS0B39v4sZZRSkHiTdLMAipBgShP5kPAi1S0RoRXFYD1sHI2v7m0mP5P+S45wI", + salt: "lneDgZnxLTb17pcdSfaKvA==", + serverB: "Ht8tUspsId2o6YnOxb/QKSLQ3vn0scNeYsV7ElRHRmXKj3b6neErfO7BCpQFvY59g1hvwwDT2fFa4Ah2nzLjRHG83W/vA/Jt0MltHSECjhV0ASfMHfMQVA6VlDLNAdAO8e4weK49v9j/AWobmdXQjBkSRTuTzMT4rKjXlmXdT54Mo3ryCPxhA2r4u+FYV4qn2vaW2GBKAj2ONMZQW0DWV90LhnnQZ6mib/mpUctOv+rTZ893ZbtytYByZtwnd91AV1QV55KNOmEqRrJEXlb3oqMVLR0FQyai2kZVH5yIT6Jri/wTfKKNkl1vIYsbM5ctc0k7UdADQYQ8070Bd1k0mQ==", + derived: "296173526d4ff2ba32b6027b25166623f12566dad89b707af650e146628e95b4", + m1: "Gfm875AAWz5Rh4ok/WHAzVuVqKPG6jwLnf/5OGyl+H4=", + m2: "XxfUcq4O5mBX/SC9s3QCv8nU0fuz5R47opb5rO9JDzE=", + }, +} + +func decode(t *testing.T, value string) []byte { + t.Helper() + decoded, err := base64.StdEncoding.DecodeString(value) + if err != nil { + t.Fatal(err) + } + + return decoded +} + +func TestSRPMatchesFastlane(t *testing.T) { + for _, vector := range srpVectors { + t.Run(vector.protocol, func(t *testing.T) { + salt := decode(t, vector.salt) + key, err := passwordKey(vector.password, salt, vector.iterations, vector.protocol) + if err != nil || hex.EncodeToString(key) != vector.derived { + t.Fatalf("password key = %x, %v", key, err) + } + + a, _ := new(big.Int).SetString(vector.a, 16) + client := newSRPClientWith(a) + if !bytes.Equal(client.A, decode(t, vector.clientA)) { + t.Errorf("A = %s", base64.StdEncoding.EncodeToString(client.A)) + } + m1, m2, err := client.proofs("dev@example.com", key, salt, decode(t, vector.serverB)) + if err != nil { + t.Fatal(err) + } + if got := base64.StdEncoding.EncodeToString(m1); got != vector.m1 { + t.Errorf("m1 = %s, want %s", got, vector.m1) + } + if got := base64.StdEncoding.EncodeToString(m2); got != vector.m2 { + t.Errorf("m2 = %s, want %s", got, vector.m2) + } + }) + } +} + +func TestSRPRejectsAnInvalidChallenge(t *testing.T) { + client := newSRPClientWith(big.NewInt(12345)) + if _, _, err := client.proofs("dev@example.com", []byte("key"), []byte("salt"), srpN.Bytes()); err == nil { + t.Error("accepted B = N") + } + if _, err := passwordKey("password", []byte("salt"), 1, "s2k_unknown"); err == nil { + t.Error("accepted an unknown protocol") + } +} + +func TestHashcashStopsWhenCancelled(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if token, err := hashcash(ctx, 64, "challenge", time.Now()); !errors.Is(err, context.Canceled) || token != "" { + t.Errorf("hashcash = %q, %v", token, err) + } +} + +func TestHashcashUsesUTC(t *testing.T) { + utc := time.Date(2023, 2, 23, 17, 6, 0, 0, time.UTC) + india := utc.In(time.FixedZone("IST", 5*3600+1800)) + got, _ := hashcash(context.Background(), 11, "4d74fb15eb23f465f1f6fcbf534e5877", india) + want, _ := hashcash(context.Background(), 11, "4d74fb15eb23f465f1f6fcbf534e5877", utc) + if got != want { + t.Errorf("hashcash in IST = %s, in UTC = %s", got, want) + } +} + +func TestHashcashMatchesSpaceship(t *testing.T) { + for _, vector := range []struct { + bits int + challenge, date, token string + }{ + {11, "4d74fb15eb23f465f1f6fcbf534e5877", "20230223170600", "1:11:20230223170600:4d74fb15eb23f465f1f6fcbf534e5877::6373"}, + {10, "abc", "20261009120000", "1:10:20261009120000:abc::1266"}, + } { + date, err := time.Parse("20060102150405", vector.date) + if err != nil { + t.Fatal(err) + } + if got, err := hashcash(context.Background(), vector.bits, vector.challenge, date); err != nil || got != vector.token { + t.Errorf("hashcash(%d, %s) = %s, want %s", vector.bits, vector.challenge, got, vector.token) + } + } +} diff --git a/templates/cli/internal/apns/expo/adapter.go b/templates/cli/internal/apns/expo/adapter.go new file mode 100644 index 0000000000..909f7752e2 --- /dev/null +++ b/templates/cli/internal/apns/expo/adapter.go @@ -0,0 +1,420 @@ +// Package expo is the APNs key setup built on @expo/apple-utils, the library +// eas-cli creates APNs keys with. +// +// It runs a small Node script embedded in the binary, with its package.json +// and a lockfile that pins @expo/apple-utils 2.2.1 (MIT) by hash. They are +// installed into the session folder on first use with npm ci +// --ignore-scripts. It runs on Node.js 18 or later, and downloads the Node.js +// LTS into the session folder when the machine has none it can use. +package expo + +import ( + "bytes" + "context" + "embed" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "regexp" + "strconv" + "strings" + + "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/apns" + "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/prompt" +) + +//go:embed helper/helper.js helper/package.json helper/package-lock.json +var helperFiles embed.FS + +const minimumNodeMajor = 18 + +// Adapter creates APNs keys with @expo/apple-utils. +type Adapter struct { + // Node and Npm are the commands to run. Empty means "node" and "npm" + // from PATH, or a downloaded Node.js when those are missing or too old. + Node string + Npm string + // For tests: where Node.js builds are downloaded from, the client, and + // the platform. Zero values mean nodejs.org and this machine. + NodeDist string + HTTP *http.Client + GOOS string + GOARCH string + // apple-utils prompts for anything the request does not answer, and for + // the two-factor code, on the terminal. Nil means the process's own. + Stdin io.Reader + Stdout io.Writer + Stderr io.Writer +} + +// New returns the adapter using node and npm from PATH and the terminal. +func New() *Adapter { + return &Adapter{} +} + +// Name is how people pick this setup. +func (a *Adapter) Name() string { + return "expo" +} + +type result struct { + KeyID string `json:"keyId"` + AppleID string `json:"appleId"` + TeamID string `json:"teamId"` + P8 string `json:"p8"` + Error string `json:"error"` + Message string `json:"message"` + Keys []struct { + ID string `json:"id"` + Name string `json:"name"` + CanRevoke bool `json:"canRevoke"` + } `json:"keys"` + // The app-id action's result. + Registered bool `json:"registered"` + Push bool `json:"push"` + Created bool `json:"created"` + PushTurnedOn bool `json:"pushTurnedOn"` +} + +// CreateKey installs the helper if needed and runs it. apple-utils always +// creates keys for both APNs environments; for a single-environment request +// that key works too. +func (a *Adapter) CreateKey(ctx context.Context, request apns.Request) (apns.Key, error) { + if request.Environment != apns.EnvironmentAll && request.Log != nil { + request.Log("The expo setup creates keys for both APNs environments; the key also works for %s.", request.Environment) + } + arguments := []string{"--name", request.Name} + if request.TeamID != "" { + arguments = append(arguments, "--team-id", request.TeamID) + } + contents, err := a.runHelper(ctx, &request, arguments...) + if err != nil { + return apns.Key{}, err + } + + return decodeResult(contents) +} + +// PrepareAppID runs the helper's app-id action: it checks the App ID, and +// after the question, or straight away without one, registers it and turns +// on Push Notifications. +func (a *Adapter) PrepareAppID(ctx context.Context, request apns.Request, appID apns.AppIDRequest) (apns.AppIDResult, error) { + // apple-utils keeps its saved sign-in per Apple ID, so without one there + // is nothing to restore. + if request.SavedSignInOnly && strings.TrimSpace(request.AppleID) == "" { + return apns.AppIDResult{}, apns.ErrSignInNeeded + } + run := func(mode string) (apns.AppIDResult, error) { + arguments := []string{"--action", "app-id", "--mode", mode, "--bundle-id", appID.BundleID, "--name", apns.AppIDName(appID.BundleID)} + if request.TeamID != "" { + arguments = append(arguments, "--team-id", request.TeamID) + } + if request.SavedSignInOnly { + arguments = append(arguments, "--saved-only", "true") + } + contents, err := a.runHelper(ctx, &request, arguments...) + if err != nil { + return apns.AppIDResult{}, err + } + // The apply run restores the check's sign-in, which apple-utils keeps + // per Apple ID. + var signedIn result + if json.Unmarshal(contents, &signedIn) == nil && signedIn.AppleID != "" { + request.AppleID = signedIn.AppleID + } + + return decodeAppIDResult(contents) + } + if !appID.Ask { + return run("apply") + } + found, err := run("check") + if err != nil || (found.Registered && found.Push) { + return found, err + } + change, err := apns.ConfirmAppIDChange(request.Asker, appID.BundleID, found.TeamID, found.Registered) + if err != nil || !change { + return found, err + } + // The check signed in (again, for a reset) and saved the session, so the + // change reuses it instead of asking again. + request.Reset = false + request.TeamID = found.TeamID + request.SavedSignInOnly = true + + return run("apply") +} + +// runHelper installs the helper if needed, runs it with arguments and returns +// the result it wrote. The terminal stays attached, since apple-utils asks +// for what signing in needs. +func (a *Adapter) runHelper(ctx context.Context, request *apns.Request, arguments ...string) ([]byte, error) { + log := request.Log + if log == nil { + log = func(string, ...any) {} + } + // For a reset the helper signs out of apple-utils before signing in. Its + // sign out asks for the Apple ID and forgets it, so the sign-in would ask + // again; asking here once and passing it to both avoids that. + // Without a terminal, asking here fails the same way the other setups do, + // instead of with apple-utils' own message. + if (request.Reset || !prompt.Interactive()) && !request.SavedSignInOnly && strings.TrimSpace(request.AppleID) == "" && request.Asker != nil { + answer, err := request.Asker.Ask("Apple ID (email)", false) + if err != nil { + return nil, err + } + request.AppleID = strings.TrimSpace(answer) + } + dir := request.SessionDir + if dir == "" { + scratch, err := os.MkdirTemp("", "appwrite-apns-expo-") + if err != nil { + return nil, err + } + defer os.RemoveAll(scratch) + dir = scratch + } + tools, err := a.toolchain(ctx, dir, log) + if err != nil { + return nil, err + } + if err := a.install(ctx, dir, tools); err != nil { + return nil, err + } + + scratch, err := os.MkdirTemp("", "appwrite-apns-expo-result-") + if err != nil { + return nil, err + } + defer os.RemoveAll(scratch) + out := filepath.Join(scratch, "result.json") + + arguments = append([]string{filepath.Join(dir, "helper.js")}, arguments...) + arguments = append(arguments, "--out", out) + if request.Reset { + arguments = append(arguments, "--reset", "true") + } + command := exec.CommandContext(ctx, tools.node, arguments...) + command.Dir = dir + command.Stdin, command.Stdout, command.Stderr = a.terminal() + // apple-utils reads the Apple ID and password from these instead of + // asking, and stores the password in the macOS Keychain unless told not + // to. The CLI never keeps the password. + command.Env = append(tools.env(), "EXPO_NO_KEYCHAIN=1") + if request.AppleID != "" { + command.Env = append(command.Env, "EXPO_APPLE_ID="+request.AppleID) + } + if request.Password != "" { + command.Env = append(command.Env, "EXPO_APPLE_PASSWORD="+request.Password) + } + runErr := command.Run() + + contents, err := os.ReadFile(out) + if err != nil { + if runErr != nil { + return nil, fmt.Errorf("the @expo/apple-utils helper failed: %w", runErr) + } + + return nil, fmt.Errorf("the @expo/apple-utils helper returned no result: %w", err) + } + + return contents, nil +} + +func decodeAppIDResult(contents []byte) (apns.AppIDResult, error) { + var decoded result + if err := json.Unmarshal(contents, &decoded); err != nil { + return apns.AppIDResult{}, fmt.Errorf("the @expo/apple-utils helper returned an unreadable result: %w", err) + } + if err := decoded.err(); err != nil { + return apns.AppIDResult{}, err + } + + return apns.AppIDResult{ + TeamID: decoded.TeamID, + Registered: decoded.Registered, + Push: decoded.Push, + Created: decoded.Created, + PushTurnedOn: decoded.PushTurnedOn, + }, nil +} + +// err is the error the helper reported, other than a full team. +func (r result) err() error { + switch r.Error { + case "": + return nil + case "sign-in-needed": + return apns.ErrSignInNeeded + case "invalid-credentials": + return fmt.Errorf("%w: %s", apns.ErrInvalidCredentials, r.Message) + default: + if r.Message == "" { + return errors.New(r.Error) + } + + return errors.New(r.Message) + } +} + +func decodeResult(contents []byte) (apns.Key, error) { + var decoded result + if err := json.Unmarshal(contents, &decoded); err != nil { + return apns.Key{}, fmt.Errorf("the @expo/apple-utils helper returned an unreadable result: %w", err) + } + switch decoded.Error { + case "": + case "max-keys": + keys := make([]apns.ExistingKey, len(decoded.Keys)) + for index, key := range decoded.Keys { + keys[index] = apns.ExistingKey{ID: key.ID, Name: key.Name, CanRevoke: key.CanRevoke} + } + + return apns.Key{}, &apns.MaxKeysError{Keys: keys} + default: + return apns.Key{}, decoded.err() + } + if decoded.KeyID == "" || decoded.P8 == "" { + return apns.Key{}, errors.New("the @expo/apple-utils helper returned no key") + } + + return apns.Key{KeyID: decoded.KeyID, TeamID: decoded.TeamID, P8: decoded.P8, AppleID: decoded.AppleID}, nil +} + +func (a *Adapter) terminal() (io.Reader, io.Writer, io.Writer) { + stdin, stdout, stderr := a.Stdin, a.Stdout, a.Stderr + if stdin == nil { + stdin = os.Stdin + } + if stdout == nil { + stdout = os.Stdout + } + if stderr == nil { + stderr = os.Stderr + } + + return stdin, stdout, stderr +} + +func (a *Adapter) npm() string { + if a.Npm != "" { + return a.Npm + } + + return "npm" +} + +var nodeVersion = regexp.MustCompile(`^v?(\d+)\.`) + +func checkNode(ctx context.Context, node string) error { + output, err := exec.CommandContext(ctx, node, "--version").Output() + if err != nil { + return fmt.Errorf("%w: the expo setup needs Node.js %d or later", apns.ErrUnavailable, minimumNodeMajor) + } + + return supportedNode(string(bytes.TrimSpace(output))) +} + +// toolchain is how node and npm are run. +type toolchain struct { + node string + npm []string + // bin is put first on PATH when node is a downloaded one. + bin string +} + +func (t toolchain) env() []string { + env := os.Environ() + if t.bin != "" { + env = append(env, "PATH="+t.bin+string(os.PathListSeparator)+os.Getenv("PATH")) + } + + return env +} + +// toolchain uses the configured node, then node and npm from PATH, then the +// Node.js downloaded into dir. A configured node is never replaced. +func (a *Adapter) toolchain(ctx context.Context, dir string, log func(string, ...any)) (toolchain, error) { + if a.Node != "" { + if err := checkNode(ctx, a.Node); err != nil { + return toolchain{}, err + } + + return toolchain{node: a.Node, npm: []string{a.npm()}}, nil + } + if checkNode(ctx, "node") == nil { + if _, err := exec.LookPath(a.npm()); err == nil { + return toolchain{node: "node", npm: []string{a.npm()}}, nil + } + } + + node, npm, err := a.downloadNode(ctx, filepath.Join(dir, "runtime"), log) + if err != nil { + return toolchain{}, fmt.Errorf("%w: the expo setup needs Node.js %d or later, and downloading it failed: %v", apns.ErrUnavailable, minimumNodeMajor, err) + } + if err := checkNode(ctx, node); err != nil { + return toolchain{}, err + } + + return toolchain{node: node, npm: []string{node, npm}, bin: filepath.Dir(node)}, nil +} + +func supportedNode(version string) error { + match := nodeVersion.FindStringSubmatch(version) + if match == nil { + return fmt.Errorf("%w: could not read the Node.js version %q", apns.ErrUnavailable, version) + } + if major, _ := strconv.Atoi(match[1]); major < minimumNodeMajor { + return fmt.Errorf("%w: the expo setup needs Node.js %d or later, found %s", apns.ErrUnavailable, minimumNodeMajor, version) + } + + return nil +} + +// install writes the embedded files into dir and runs npm ci when the +// lockfile changed or the dependencies are missing. The lockfile pins the +// exact package hashes, and --ignore-scripts keeps install scripts from +// running. +func (a *Adapter) install(ctx context.Context, dir string, tools toolchain) error { + if err := os.MkdirAll(dir, 0o700); err != nil { + return err + } + changed := false + for _, name := range []string{"helper.js", "package.json", "package-lock.json"} { + contents, err := helperFiles.ReadFile("helper/" + name) + if err != nil { + return err + } + path := filepath.Join(dir, name) + if current, err := os.ReadFile(path); err == nil && bytes.Equal(current, contents) { + continue + } + if err := os.WriteFile(path, contents, 0o600); err != nil { + return err + } + changed = true + } + + if _, err := os.Stat(filepath.Join(dir, "node_modules", "@expo", "apple-utils", "package.json")); err == nil && !changed { + return nil + } + _, _, stderr := a.terminal() + arguments := append(append([]string{}, tools.npm[1:]...), "ci", "--ignore-scripts", "--no-audit", "--no-fund", "--loglevel=error") + command := exec.CommandContext(ctx, tools.npm[0], arguments...) + command.Dir = dir + command.Env = tools.env() + command.Stdout, command.Stderr = stderr, stderr + if err := command.Run(); err != nil { + // A failed install must not look complete on the next run. + _ = os.Remove(filepath.Join(dir, "package-lock.json")) + + return fmt.Errorf("%w: npm ci failed in %s: %v", apns.ErrUnavailable, dir, err) + } + + return nil +} diff --git a/templates/cli/internal/apns/expo/adapter_test.go b/templates/cli/internal/apns/expo/adapter_test.go new file mode 100644 index 0000000000..73e7894ff2 --- /dev/null +++ b/templates/cli/internal/apns/expo/adapter_test.go @@ -0,0 +1,335 @@ +package expo + +import ( + "context" + "errors" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/apns" +) + +func TestSupportedNode(t *testing.T) { + for version, supported := range map[string]bool{ + "v18.0.0": true, + "v22.11.0": true, + "v16.20.2": false, + "v8.9.0": false, + "garbage": false, + } { + err := supportedNode(version) + if (err == nil) != supported { + t.Errorf("supportedNode(%q) = %v", version, err) + } + if err != nil && !errors.Is(err, apns.ErrUnavailable) { + t.Errorf("supportedNode(%q) is not ErrUnavailable: %v", version, err) + } + } +} + +func TestDecodeResult(t *testing.T) { + key, err := decodeResult([]byte(`{"keyId":"KEY1234567","teamId":"ABCDE12345","p8":"-----BEGIN PRIVATE KEY-----","appleId":"dev@example.com"}`)) + if err != nil || key.KeyID != "KEY1234567" || key.TeamID != "ABCDE12345" || key.AppleID != "dev@example.com" { + t.Errorf("key = %+v, %v", key, err) + } + + _, err = decodeResult([]byte(`{"error":"max-keys","keys":[{"id":"OLD1234567","name":"Old","canRevoke":true}]}`)) + var maxKeys *apns.MaxKeysError + if !errors.As(err, &maxKeys) || len(maxKeys.Keys) != 1 || maxKeys.Keys[0].ID != "OLD1234567" || maxKeys.Keys[0].Name != "Old" { + t.Errorf("max keys = %v", err) + } + + _, err = decodeResult([]byte(`{"error":"invalid-credentials","message":"Invalid username and password combination"}`)) + if !errors.Is(err, apns.ErrInvalidCredentials) { + t.Errorf("credentials = %v", err) + } + + if _, err := decodeResult([]byte(`{"error":"failed","message":"Apple is down"}`)); err == nil || err.Error() != "Apple is down" { + t.Errorf("failure = %v", err) + } + if _, err := decodeResult([]byte(`{}`)); err == nil { + t.Error("an empty result was accepted") + } +} + +// fakeTools writes node and npm stand-ins that log their arguments. The node +// stand-in answers --version and writes result to the --out file. +func fakeTools(t *testing.T, version, result string) (string, string, string) { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("shell stand-ins") + } + bin := t.TempDir() + log := filepath.Join(bin, "log") + node := `#!/bin/sh +if [ "$1" = "--version" ]; then echo "` + version + `"; exit 0; fi +echo "node $* EXPO_NO_KEYCHAIN=$EXPO_NO_KEYCHAIN EXPO_APPLE_ID=$EXPO_APPLE_ID" >> "` + log + `" +while [ $# -gt 0 ]; do + if [ "$1" = "--out" ]; then printf '%s' '` + result + `' > "$2"; fi + shift +done +` + npm := `#!/bin/sh +echo "npm $*" >> "` + log + `" +mkdir -p node_modules/@expo/apple-utils && echo '{}' > node_modules/@expo/apple-utils/package.json +` + for name, script := range map[string]string{"node": node, "npm": npm} { + if err := os.WriteFile(filepath.Join(bin, name), []byte(script), 0o700); err != nil { + t.Fatal(err) + } + } + + return filepath.Join(bin, "node"), filepath.Join(bin, "npm"), log +} + +func TestCreateKeyInstallsOnceAndRunsTheHelper(t *testing.T) { + node, npm, log := fakeTools(t, "v20.1.0", `{"keyId":"KEY1234567","teamId":"ABCDE12345","p8":"pem"}`) + adapter := &Adapter{Node: node, Npm: npm, Stdout: &strings.Builder{}, Stderr: &strings.Builder{}} + request := apns.Request{ + TeamID: "ABCDE12345", + Name: "Appwrite Push", + Environment: apns.EnvironmentAll, + AppleID: "dev@example.com", + SessionDir: filepath.Join(t.TempDir(), "expo"), + } + + for range 2 { + key, err := adapter.CreateKey(context.Background(), request) + if err != nil || key.KeyID != "KEY1234567" || key.P8 != "pem" { + t.Fatalf("key = %+v, %v", key, err) + } + } + + calls, _ := os.ReadFile(log) + lines := strings.Split(strings.TrimSpace(string(calls)), "\n") + if len(lines) != 3 || !strings.HasPrefix(lines[0], "npm ci --ignore-scripts") { + t.Fatalf("calls:\n%s", calls) + } + for _, line := range lines[1:] { + if !strings.Contains(line, "helper.js --name Appwrite Push --team-id ABCDE12345 --out ") || + !strings.HasSuffix(line, " EXPO_NO_KEYCHAIN=1 EXPO_APPLE_ID=dev@example.com") { + t.Errorf("helper call = %s", line) + } + } + for _, name := range []string{"helper.js", "package.json", "package-lock.json"} { + if _, err := os.Stat(filepath.Join(request.SessionDir, name)); err != nil { + t.Errorf("%s was not installed: %v", name, err) + } + } +} + +// Without a team, the helper gets no --team-id and apple-utils picks the +// account's only team or asks, the flow the CLI uses when no team is known. +func TestCreateKeyWithoutATeamNotesTheEnvironment(t *testing.T) { + node, npm, log := fakeTools(t, "v20.1.0", `{"keyId":"KEY1234567","teamId":"ABCDE12345","p8":"pem"}`) + adapter := &Adapter{Node: node, Npm: npm, Stdout: &strings.Builder{}, Stderr: &strings.Builder{}} + var logged []string + request := apns.Request{ + Name: "Appwrite Push", + Environment: apns.EnvironmentSandbox, + SessionDir: t.TempDir(), + Log: func(format string, args ...any) { logged = append(logged, format) }, + } + + if _, err := adapter.CreateKey(context.Background(), request); err != nil { + t.Fatal(err) + } + if len(logged) != 1 || !strings.Contains(logged[0], "both APNs environments") { + t.Errorf("logged %v", logged) + } + calls, _ := os.ReadFile(log) + lines := strings.Split(strings.TrimSpace(string(calls)), "\n") + helper := lines[len(lines)-1] + if !strings.Contains(helper, "helper.js --name Appwrite Push --out ") || strings.Contains(helper, "--team-id") { + t.Errorf("helper call = %s", helper) + } +} + +func TestCreateKeyNeedsARecentNode(t *testing.T) { + node, npm, log := fakeTools(t, "v16.20.2", `{}`) + adapter := &Adapter{Node: node, Npm: npm} + request := apns.Request{Name: "Appwrite Push", Environment: apns.EnvironmentAll, SessionDir: t.TempDir()} + + if _, err := adapter.CreateKey(context.Background(), request); !errors.Is(err, apns.ErrUnavailable) { + t.Fatalf("err = %v", err) + } + if _, err := os.Stat(log); err == nil { + t.Error("ran npm or the helper with an old Node.js") + } + + missing := &Adapter{Node: filepath.Join(t.TempDir(), "node")} + if _, err := missing.CreateKey(context.Background(), request); !errors.Is(err, apns.ErrUnavailable) { + t.Fatalf("missing node err = %v", err) + } +} + +func TestCreateKeyPassesResetToTheHelper(t *testing.T) { + node, npm, log := fakeTools(t, "v20.1.0", `{"keyId":"KEY1234567","teamId":"ABCDE12345","p8":"pem"}`) + adapter := &Adapter{Node: node, Npm: npm, Stdout: &strings.Builder{}, Stderr: &strings.Builder{}} + request := apns.Request{Name: "Appwrite Push", Environment: apns.EnvironmentAll, SessionDir: t.TempDir(), Reset: true} + + if _, err := adapter.CreateKey(context.Background(), request); err != nil { + t.Fatal(err) + } + calls, _ := os.ReadFile(log) + lines := strings.Split(strings.TrimSpace(string(calls)), "\n") + if helper := lines[len(lines)-1]; !strings.Contains(helper, " --reset true ") { + t.Errorf("helper call = %s", helper) + } +} + +type oneAnswer struct { + answer string + asked []string +} + +func (o *oneAnswer) Ask(question string, secret bool) (string, error) { + o.asked = append(o.asked, question) + + return o.answer, nil +} + +func (o *oneAnswer) Choose(string, []string) (int, error) { + return 0, errors.New("not expected") +} + +// A reset asks for the Apple ID once and hands it to apple-utils, whose sign +// out would otherwise ask, forget it, and leave the sign-in to ask again. +func TestResetAsksForTheAppleIDOnce(t *testing.T) { + node, npm, log := fakeTools(t, "v20.1.0", `{"keyId":"KEY1234567","teamId":"ABCDE12345","p8":"pem"}`) + adapter := &Adapter{Node: node, Npm: npm, Stdout: &strings.Builder{}, Stderr: &strings.Builder{}} + asker := &oneAnswer{answer: "dev@example.com"} + request := apns.Request{Name: "Appwrite Push", Environment: apns.EnvironmentAll, SessionDir: t.TempDir(), Reset: true, Asker: asker} + + if _, err := adapter.CreateKey(context.Background(), request); err != nil { + t.Fatal(err) + } + if strings.Join(asker.asked, "|") != "Apple ID (email)" { + t.Errorf("asked %v", asker.asked) + } + calls, _ := os.ReadFile(log) + lines := strings.Split(strings.TrimSpace(string(calls)), "\n") + if helper := lines[len(lines)-1]; !strings.HasSuffix(helper, "EXPO_APPLE_ID=dev@example.com") || !strings.Contains(helper, " --reset true") { + t.Errorf("helper call = %s", helper) + } + + // With the Apple ID known, nothing is asked. + known := &oneAnswer{} + request.Asker, request.AppleID = known, "dev@example.com" + if _, err := adapter.CreateKey(context.Background(), request); err != nil || len(known.asked) != 0 { + t.Errorf("asked %v, %v", known.asked, err) + } +} + +// choosing answers Choose with choice and records the question. +type choosing struct { + asked []string + choice int +} + +func (c *choosing) Ask(question string, _ bool) (string, error) { + return "", errors.New("not expected: " + question) +} + +func (c *choosing) Choose(question string, _ []string) (int, error) { + c.asked = append(c.asked, question) + + return c.choice, nil +} + +func helperCalls(t *testing.T, log string) []string { + t.Helper() + calls, _ := os.ReadFile(log) + var helper []string + for _, line := range strings.Split(strings.TrimSpace(string(calls)), "\n") { + if strings.Contains(line, "helper.js") { + helper = append(helper, line) + } + } + + return helper +} + +func TestPrepareAppIDWithoutAskingAppliesInOneRun(t *testing.T) { + node, npm, log := fakeTools(t, "v20.1.0", `{"teamId":"ABCDE12345","registered":true,"push":true,"created":true,"pushTurnedOn":true}`) + adapter := &Adapter{Node: node, Npm: npm, Stdout: &strings.Builder{}, Stderr: &strings.Builder{}} + request := apns.Request{TeamID: "ABCDE12345", AppleID: "dev@example.com", SessionDir: t.TempDir()} + + result, err := adapter.PrepareAppID(context.Background(), request, apns.AppIDRequest{BundleID: "io.example.app"}) + if err != nil || result != (apns.AppIDResult{TeamID: "ABCDE12345", Registered: true, Push: true, Created: true, PushTurnedOn: true}) { + t.Fatalf("result = %+v, %v", result, err) + } + calls := helperCalls(t, log) + if len(calls) != 1 || !strings.Contains(calls[0], "helper.js --action app-id --mode apply --bundle-id io.example.app --name io example app --team-id ABCDE12345 --out ") { + t.Errorf("helper calls = %v", calls) + } +} + +func TestPrepareAppIDOnlyChecksAReadyAppID(t *testing.T) { + node, npm, log := fakeTools(t, "v20.1.0", `{"teamId":"ABCDE12345","registered":true,"push":true}`) + adapter := &Adapter{Node: node, Npm: npm, Stdout: &strings.Builder{}, Stderr: &strings.Builder{}} + asker := &choosing{} + request := apns.Request{AppleID: "dev@example.com", SessionDir: t.TempDir(), Asker: asker} + + result, err := adapter.PrepareAppID(context.Background(), request, apns.AppIDRequest{BundleID: "io.example.app", Ask: true}) + if err != nil || !result.Registered || !result.Push || result.Created { + t.Fatalf("result = %+v, %v", result, err) + } + if calls := helperCalls(t, log); len(calls) != 1 || !strings.Contains(calls[0], "--mode check") || len(asker.asked) != 0 { + t.Errorf("helper calls = %v, asked %v", calls, asker.asked) + } +} + +func TestPrepareAppIDAsksBeforeApplying(t *testing.T) { + node, npm, log := fakeTools(t, "v20.1.0", `{"teamId":"ABCDE12345","registered":false,"push":false}`) + adapter := &Adapter{Node: node, Npm: npm, Stdout: &strings.Builder{}, Stderr: &strings.Builder{}} + asker := &choosing{} + request := apns.Request{AppleID: "dev@example.com", SessionDir: t.TempDir(), Asker: asker, Reset: true} + + if _, err := adapter.PrepareAppID(context.Background(), request, apns.AppIDRequest{BundleID: "io.example.app", Ask: true}); err != nil { + t.Fatal(err) + } + calls := helperCalls(t, log) + if len(calls) != 2 || !strings.Contains(calls[0], "--mode check") || !strings.Contains(calls[0], "--reset true") || + !strings.Contains(calls[1], "--mode apply") || !strings.Contains(calls[1], "--team-id ABCDE12345") || strings.Contains(calls[1], "--reset") || + !strings.Contains(calls[1], "--saved-only true") || !strings.HasSuffix(calls[1], "EXPO_APPLE_ID=dev@example.com") { + t.Errorf("helper calls = %v", calls) + } + if len(asker.asked) != 1 || !strings.HasPrefix(asker.asked[0], "io.example.app is not registered as an App ID on team ABCDE12345") { + t.Errorf("asked %v", asker.asked) + } + + // Declining runs nothing more. + declined := &choosing{choice: 1} + request.Asker = declined + before := len(helperCalls(t, log)) + result, err := adapter.PrepareAppID(context.Background(), request, apns.AppIDRequest{BundleID: "io.example.app", Ask: true}) + if err != nil || result.Registered || len(helperCalls(t, log)) != before+1 { + t.Errorf("result = %+v, %v, calls %v", result, err, helperCalls(t, log)) + } +} + +func TestPrepareAppIDWithTheSavedSignInOnly(t *testing.T) { + node, npm, log := fakeTools(t, "v20.1.0", `{"error":"sign-in-needed"}`) + adapter := &Adapter{Node: node, Npm: npm, Stdout: &strings.Builder{}, Stderr: &strings.Builder{}} + request := apns.Request{SessionDir: t.TempDir(), SavedSignInOnly: true} + + // apple-utils keeps sessions per Apple ID: without one there is none. + if _, err := adapter.PrepareAppID(context.Background(), request, apns.AppIDRequest{BundleID: "io.example.app", Ask: true}); !errors.Is(err, apns.ErrSignInNeeded) { + t.Fatalf("err = %v", err) + } + if calls := helperCalls(t, log); len(calls) != 0 { + t.Fatalf("ran the helper without an Apple ID: %v", calls) + } + + request.AppleID = "dev@example.com" + if _, err := adapter.PrepareAppID(context.Background(), request, apns.AppIDRequest{BundleID: "io.example.app", Ask: true}); !errors.Is(err, apns.ErrSignInNeeded) { + t.Fatalf("err = %v", err) + } + if calls := helperCalls(t, log); len(calls) != 1 || !strings.Contains(calls[0], "--saved-only true") { + t.Errorf("helper calls = %v", calls) + } +} diff --git a/templates/cli/internal/apns/expo/helper/helper.js b/templates/cli/internal/apns/expo/helper/helper.js new file mode 100644 index 0000000000..6fc8ea96a9 --- /dev/null +++ b/templates/cli/internal/apns/expo/helper/helper.js @@ -0,0 +1,163 @@ +'use strict'; + +// Creates a team-scoped APNs key, or prepares the app's App ID, through the +// Apple Developer portal with @expo/apple-utils, the library eas-cli uses for +// the same jobs. +// +// apple-utils asks for the Apple ID, the password and the two-factor code +// itself, so the CLI runs this script with the terminal attached. Its logging +// goes to stdout, so the result is written to the file named by --out instead. + +const fs = require('fs'); +const { + Auth, + BundleId, + CapabilityType, + CapabilityTypeOption, + InvalidUserCredentialsError, + Keys, +} = require('@expo/apple-utils'); + +function parseArguments(argv) { + const values = {}; + for (let index = 0; index < argv.length; index += 2) { + values[argv[index].replace(/^--/, '')] = argv[index + 1]; + } + if (!values.out || !values.name) { + throw new Error( + 'usage: helper.js [--action app-id --bundle-id --mode check|apply [--saved-only true]] [--team-id ] [--reset true] --name --out ' + ); + } + + return values; +} + +// Apple words a full team as "maximum allowed number of Keys" or, since 2026, +// "maximum allowed number of team scoped Keys ...", which apple-utils does not +// turn into MaxKeysCreatedError, so the message itself is checked too. +const maxKeysMessage = /maximum allowed number of (?:[a-z ]+ )?keys/i; + +function isMaxKeysError(error) { + return ( + error instanceof Keys.MaxKeysCreatedError || + maxKeysMessage.test(error?.rawDump?.resultString ?? '') || + maxKeysMessage.test(error?.message ?? '') + ); +} + +// signIn returns the signed-in auth state, or null when --saved-only finds no +// saved sign-in for EXPO_APPLE_ID. +async function signIn({ 'team-id': teamId, reset, 'saved-only': savedOnly }) { + // --reset deletes apple-utils' saved session for this Apple ID first, so the + // sign-in starts over, two-factor included. + if (reset === 'true') { + await Auth.logoutAsync({}); + } + let authState; + if (savedOnly === 'true') { + authState = await Auth.tryRestoringAuthStateFromUserCredentialsAsync( + { username: process.env.EXPO_APPLE_ID, teamId }, + { autoResolveProvider: true } + ); + if (!authState) { + return null; + } + } else { + // Without a team ID, apple-utils asks which team to use when there are several. + authState = await Auth.loginAsync(teamId ? { teamId } : {}, { autoResolveProvider: true }); + } + const context = authState.context; + if (teamId && context.teamId !== teamId) { + throw new Error(`Signed in to Apple team ${context.teamId}, not ${teamId}`); + } + + return authState; +} + +async function createKey(context, { name }) { + let key; + try { + key = await Keys.createKeyAsync(context, { name, isApns: true }); + } catch (error) { + if (!isMaxKeysError(error)) { + throw error; + } + const keys = await Keys.getKeysAsync(context); + + return { + error: 'max-keys', + keys: keys.map(({ id, name, canRevoke }) => ({ id, name, canRevoke })), + }; + } + const p8 = await Keys.downloadKeyAsync(context, { id: key.id }); + + return { keyId: key.id, teamId: context.teamId, p8 }; +} + +// prepareAppId reports whether --bundle-id is an App ID with Push +// Notifications, and with --mode apply registers it and turns push on. +async function prepareAppId(context, { 'bundle-id': identifier, name, mode }) { + let bundleId = await BundleId.findAsync(context, { identifier }); + const result = { + teamId: context.teamId, + registered: Boolean(bundleId), + push: false, + created: false, + pushTurnedOn: false, + }; + if (bundleId) { + result.push = Boolean(await bundleId.hasCapabilityAsync(CapabilityType.PUSH_NOTIFICATIONS)); + } + if (mode !== 'apply') { + return result; + } + if (!bundleId) { + bundleId = await BundleId.createAsync(context, { name, identifier }); + result.registered = true; + result.created = true; + } + if (!result.push) { + await bundleId.updateBundleIdCapabilityAsync({ + capabilityType: CapabilityType.PUSH_NOTIFICATIONS, + option: CapabilityTypeOption.ON, + }); + result.push = true; + result.pushTurnedOn = true; + } + + return result; +} + +async function run(options) { + const authState = await signIn(options); + if (!authState) { + return { error: 'sign-in-needed' }; + } + const result = + options.action === 'app-id' + ? await prepareAppId(authState.context, options) + : await createKey(authState.context, options); + + // The Apple ID lets later runs reuse this sign-in without asking. + return { ...result, appleId: authState.username }; +} + +async function main() { + const options = parseArguments(process.argv.slice(2)); + let result; + try { + result = await run(options); + } catch (error) { + const kind = error instanceof InvalidUserCredentialsError ? 'invalid-credentials' : 'failed'; + result = { error: kind, message: error?.message ?? String(error) }; + } + fs.writeFileSync(options.out, JSON.stringify(result), { mode: 0o600 }); + if (result.error) { + process.exitCode = 1; + } +} + +main().catch(error => { + console.error(error?.message ?? error); + process.exitCode = 1; +}); diff --git a/templates/cli/internal/apns/expo/helper/package-lock.json b/templates/cli/internal/apns/expo/helper/package-lock.json new file mode 100644 index 0000000000..cd61fd039f --- /dev/null +++ b/templates/cli/internal/apns/expo/helper/package-lock.json @@ -0,0 +1,25 @@ +{ + "name": "appwrite-apple-helper", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "appwrite-apple-helper", + "dependencies": { + "@expo/apple-utils": "2.2.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@expo/apple-utils": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@expo/apple-utils/-/apple-utils-2.2.1.tgz", + "integrity": "sha512-Jr0yawuiJ83Lu76krKJYysXfvSC678YK8nIBHUoNjrOAQT392LttbfsoPA+8/zDBZOrfo+8eXgBq0a51b7wBVQ==", + "license": "MIT", + "bin": { + "apple-utils": "bin.js" + } + } + } +} diff --git a/templates/cli/internal/apns/expo/helper/package.json b/templates/cli/internal/apns/expo/helper/package.json new file mode 100644 index 0000000000..2e4c5e4598 --- /dev/null +++ b/templates/cli/internal/apns/expo/helper/package.json @@ -0,0 +1,11 @@ +{ + "name": "appwrite-apple-helper", + "private": true, + "description": "Creates an APNs key through the Apple Developer portal for appwrite init apns --create-key", + "engines": { + "node": ">=18" + }, + "dependencies": { + "@expo/apple-utils": "2.2.1" + } +} diff --git a/templates/cli/internal/apns/expo/runtime.go b/templates/cli/internal/apns/expo/runtime.go new file mode 100644 index 0000000000..fb7e357d4c --- /dev/null +++ b/templates/cli/internal/apns/expo/runtime.go @@ -0,0 +1,278 @@ +package expo + +import ( + "archive/tar" + "archive/zip" + "compress/gzip" + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "runtime" + "strings" + "time" +) + +// When the machine has no Node.js the expo setup can use, it downloads this +// release, the Active LTS, into the session folder. Nothing is installed +// system-wide. Each build is checked against its SHA-256 from +// https://nodejs.org/dist/v24.21.0/SHASUMS256.txt before it is unpacked. +const nodeRelease = "v24.21.0" + +var nodeChecksums = map[string]string{ + "node-v24.21.0-darwin-arm64.tar.gz": "bed7eea5325e1108f32ce5228ddd6a5f0f08a499ee42aa7442aea583702f6057", + "node-v24.21.0-darwin-x64.tar.gz": "1462cb3b3046b815cf8ea436d3da450ec1a9f11dac7e5a46b0ada5305d7e8097", + "node-v24.21.0-linux-arm64.tar.gz": "724282c3b43aec998aa9527380465b45d229e021b58035f5f4f63095eabfe5d5", + "node-v24.21.0-linux-x64.tar.gz": "6e1db87ef58b8819e5d5402eff1536491b18edd8eb7bee5ef7897876e88dc5ff", + "node-v24.21.0-win-arm64.zip": "8779b1bde1d39f8d420e3b57aa657b39891af434d3de44a919044cec06785921", + "node-v24.21.0-win-x64.zip": "158f7685b44de51f6c0df1d153526cbcd3e1bc739a8dfc607721cef75de9e541", +} + +const defaultNodeDist = "https://nodejs.org/dist" + +// nodeBuild names the official build for goos/goarch, without its extension, +// and the archive format it comes in. +func nodeBuild(goos, goarch string) (string, string, error) { + platform := map[string]string{"darwin": "darwin", "linux": "linux", "windows": "win"}[goos] + arch := map[string]string{"amd64": "x64", "arm64": "arm64"}[goarch] + if platform == "" || arch == "" { + return "", "", fmt.Errorf("no Node.js build to download for %s/%s", goos, goarch) + } + extension := ".tar.gz" + if goos == "windows" { + extension = ".zip" + } + + return "node-" + nodeRelease + "-" + platform + "-" + arch, extension, nil +} + +// nodePaths are the node binary and npm's entry script inside an unpacked +// build. npm is run through node, so its shell wrappers are not needed. +func nodePaths(root, goos string) (string, string) { + if goos == "windows" { + return filepath.Join(root, "node.exe"), filepath.Join(root, "node_modules", "npm", "bin", "npm-cli.js") + } + + return filepath.Join(root, "bin", "node"), filepath.Join(root, "lib", "node_modules", "npm", "bin", "npm-cli.js") +} + +// downloadNode makes sure the pinned Node.js is unpacked under dir and returns +// the node binary and npm's entry script. +func (a *Adapter) downloadNode(ctx context.Context, dir string, log func(string, ...any)) (string, string, error) { + goos, goarch := a.platform() + name, extension, err := nodeBuild(goos, goarch) + if err != nil { + return "", "", err + } + root := filepath.Join(dir, name) + node, npm := nodePaths(root, goos) + if _, err := os.Stat(npm); err == nil { + return node, npm, nil + } + archive := name + extension + checksum, ok := nodeChecksums[archive] + if !ok { + return "", "", fmt.Errorf("no checksum for %s", archive) + } + + dist := a.NodeDist + if dist == "" { + dist = defaultNodeDist + } + log("Node.js %d or later was not found. Downloading Node.js %s (about 50 MB, 200 MB unpacked) into %s ...", minimumNodeMajor, nodeRelease, dir) + if err := os.MkdirAll(dir, 0o700); err != nil { + return "", "", err + } + downloaded, err := a.fetch(ctx, dist+"/"+nodeRelease+"/"+archive, dir, checksum) + if err != nil { + return "", "", err + } + defer os.Remove(downloaded) + + // Unpack next to the final folder and move it into place, so a failed or + // interrupted unpack never looks like a usable runtime. + staging, err := os.MkdirTemp(dir, ".node-") + if err != nil { + return "", "", err + } + defer os.RemoveAll(staging) + if extension == ".zip" { + err = unzip(downloaded, staging) + } else { + err = untar(downloaded, staging) + } + if err != nil { + return "", "", fmt.Errorf("could not unpack %s: %w", archive, err) + } + // Publishing is a rename, which fails when another run published first; + // that runtime is complete, so it is used rather than replaced. An + // existing runtime is never removed, as another run may be using it. + if err := os.Rename(filepath.Join(staging, name), root); err != nil { + if _, statErr := os.Stat(npm); statErr == nil { + return node, npm, nil + } + + return "", "", fmt.Errorf("could not unpack %s: %w", archive, err) + } + if _, err := os.Stat(npm); err != nil { + return "", "", fmt.Errorf("%s has no npm", archive) + } + + return node, npm, nil +} + +func (a *Adapter) platform() (string, string) { + if a.GOOS != "" { + return a.GOOS, a.GOARCH + } + + return runtime.GOOS, runtime.GOARCH +} + +// fetch downloads url into a file in dir and checks its SHA-256. +func (a *Adapter) fetch(ctx context.Context, url, dir, checksum string) (string, error) { + client := a.HTTP + if client == nil { + client = &http.Client{Timeout: 10 * time.Minute} + } + request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return "", err + } + response, err := client.Do(request) + if err != nil { + return "", fmt.Errorf("could not download Node.js: %w", err) + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + return "", fmt.Errorf("could not download Node.js from %s (HTTP %d)", url, response.StatusCode) + } + + file, err := os.CreateTemp(dir, ".node-download-") + if err != nil { + return "", err + } + hash := sha256.New() + _, err = io.Copy(io.MultiWriter(file, hash), response.Body) + if closeErr := file.Close(); err == nil { + err = closeErr + } + if err != nil { + os.Remove(file.Name()) + + return "", fmt.Errorf("could not download Node.js: %w", err) + } + if got := hex.EncodeToString(hash.Sum(nil)); got != checksum { + os.Remove(file.Name()) + + return "", fmt.Errorf("the Node.js download from %s does not match its checksum (got %s)", url, got) + } + + return file.Name(), nil +} + +// inside returns where name unpacks under root, refusing anything that would +// land outside it. +func inside(root, name string) (string, error) { + clean := filepath.Clean(filepath.FromSlash(name)) + if filepath.IsAbs(clean) || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) { + return "", fmt.Errorf("unsafe path %q in the archive", name) + } + + return filepath.Join(root, clean), nil +} + +func writeFile(path string, mode os.FileMode, contents io.Reader) error { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, mode&0o755|0o600) + if err != nil { + return err + } + _, err = io.Copy(file, contents) + if closeErr := file.Close(); err == nil { + err = closeErr + } + + return err +} + +// untar unpacks a .tar.gz into root. Symbolic links are skipped: they are +// only npm's and npx's shell entry points, and npm is run through node. +func untar(archive, root string) error { + file, err := os.Open(archive) + if err != nil { + return err + } + defer file.Close() + compressed, err := gzip.NewReader(file) + if err != nil { + return err + } + reader := tar.NewReader(compressed) + for { + header, err := reader.Next() + if errors.Is(err, io.EOF) { + return nil + } + if err != nil { + return err + } + path, err := inside(root, header.Name) + if err != nil { + return err + } + switch header.Typeflag { + case tar.TypeDir: + if err := os.MkdirAll(path, 0o755); err != nil { + return err + } + case tar.TypeReg: + if err := writeFile(path, header.FileInfo().Mode(), reader); err != nil { + return err + } + } + } +} + +// unzip unpacks a .zip into root, with the same rules as untar. +func unzip(archive, root string) error { + reader, err := zip.OpenReader(archive) + if err != nil { + return err + } + defer reader.Close() + for _, entry := range reader.File { + path, err := inside(root, entry.Name) + if err != nil { + return err + } + if entry.FileInfo().IsDir() { + if err := os.MkdirAll(path, 0o755); err != nil { + return err + } + + continue + } + if !entry.FileInfo().Mode().IsRegular() { + continue + } + contents, err := entry.Open() + if err != nil { + return err + } + err = writeFile(path, entry.FileInfo().Mode(), contents) + contents.Close() + if err != nil { + return err + } + } + + return nil +} diff --git a/templates/cli/internal/apns/expo/runtime_test.go b/templates/cli/internal/apns/expo/runtime_test.go new file mode 100644 index 0000000000..5d7c2319e2 --- /dev/null +++ b/templates/cli/internal/apns/expo/runtime_test.go @@ -0,0 +1,237 @@ +package expo + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "context" + "crypto/sha256" + "encoding/hex" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "strings" + "sync/atomic" + "testing" + + "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/apns" +) + +func TestNodeBuild(t *testing.T) { + for _, test := range []struct{ goos, goarch, name, extension string }{ + {"darwin", "arm64", "node-v24.21.0-darwin-arm64", ".tar.gz"}, + {"darwin", "amd64", "node-v24.21.0-darwin-x64", ".tar.gz"}, + {"linux", "amd64", "node-v24.21.0-linux-x64", ".tar.gz"}, + {"windows", "arm64", "node-v24.21.0-win-arm64", ".zip"}, + } { + name, extension, err := nodeBuild(test.goos, test.goarch) + if err != nil || name != test.name || extension != test.extension { + t.Errorf("nodeBuild(%s, %s) = %s, %s, %v", test.goos, test.goarch, name, extension, err) + } + if _, ok := nodeChecksums[name+extension]; !ok { + t.Errorf("no checksum for %s%s", name, extension) + } + } + if _, _, err := nodeBuild("plan9", "386"); err == nil { + t.Error("found a build for plan9/386") + } +} + +type entry struct { + name string + mode int64 + contents string + link string +} + +func tarball(t *testing.T, entries []entry) []byte { + t.Helper() + var buffer bytes.Buffer + compressed := gzip.NewWriter(&buffer) + writer := tar.NewWriter(compressed) + for _, item := range entries { + header := &tar.Header{Name: item.name, Mode: item.mode, Size: int64(len(item.contents)), Typeflag: tar.TypeReg} + if item.link != "" { + header = &tar.Header{Name: item.name, Mode: 0o777, Linkname: item.link, Typeflag: tar.TypeSymlink} + } + if err := writer.WriteHeader(header); err != nil { + t.Fatal(err) + } + if _, err := writer.Write([]byte(item.contents)); err != nil { + t.Fatal(err) + } + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + if err := compressed.Close(); err != nil { + t.Fatal(err) + } + + return buffer.Bytes() +} + +// fakeNodeBuild is a Node.js build whose node is a shell script: it answers +// --version, acts as npm when given npm-cli.js, and otherwise runs as the +// helper, writing a key to the --out file. +func fakeNodeBuild(t *testing.T) (string, []byte) { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("shell stand-ins") + } + name, _, err := nodeBuild(runtime.GOOS, runtime.GOARCH) + if err != nil { + t.Skip(err) + } + node := `#!/bin/sh +if [ "$1" = "--version" ]; then echo "v24.21.0"; exit 0; fi +case "$1" in + *npm-cli.js) mkdir -p node_modules/@expo/apple-utils && echo '{}' > node_modules/@expo/apple-utils/package.json; exit 0 ;; +esac +while [ $# -gt 0 ]; do + if [ "$1" = "--out" ]; then printf '%s' '{"keyId":"KEY1234567","teamId":"ABCDE12345","p8":"pem"}' > "$2"; fi + shift +done +` + + return name, tarball(t, []entry{ + {name: name + "/bin/node", mode: 0o755, contents: node}, + {name: name + "/lib/node_modules/npm/bin/npm-cli.js", mode: 0o644, contents: "// npm"}, + {name: name + "/bin/npm", link: "../lib/node_modules/npm/bin/npm-cli.js"}, + }) +} + +// serve answers the Node.js download, counting requests, and makes archive +// the expected build for this machine. +func serve(t *testing.T, name string, archive []byte) (*httptest.Server, *atomic.Int32) { + t.Helper() + sum := sha256.Sum256(archive) + previous := nodeChecksums[name+".tar.gz"] + nodeChecksums[name+".tar.gz"] = hex.EncodeToString(sum[:]) + t.Cleanup(func() { nodeChecksums[name+".tar.gz"] = previous }) + + requests := &atomic.Int32{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + if r.URL.Path != "/"+nodeRelease+"/"+name+".tar.gz" { + w.WriteHeader(http.StatusNotFound) + + return + } + _, _ = w.Write(archive) + })) + t.Cleanup(server.Close) + + return server, requests +} + +// withoutNode leaves only mkdir on PATH, so neither node nor npm is found. +func withoutNode(t *testing.T) { + t.Helper() + bin := t.TempDir() + if err := os.Symlink("/bin/mkdir", filepath.Join(bin, "mkdir")); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", bin) +} + +func TestCreateKeyDownloadsNodeWhenMissing(t *testing.T) { + name, archive := fakeNodeBuild(t) + server, requests := serve(t, name, archive) + withoutNode(t) + + adapter := &Adapter{NodeDist: server.URL, HTTP: server.Client(), Stdout: &strings.Builder{}, Stderr: &strings.Builder{}} + var logged []string + request := apns.Request{ + TeamID: "ABCDE12345", Name: "Appwrite Push", Environment: apns.EnvironmentAll, SessionDir: t.TempDir(), + Log: func(format string, args ...any) { logged = append(logged, format) }, + } + + for range 2 { + key, err := adapter.CreateKey(context.Background(), request) + if err != nil || key.KeyID != "KEY1234567" { + t.Fatalf("key = %+v, %v", key, err) + } + } + if requests.Load() != 1 { + t.Errorf("downloaded Node.js %d times", requests.Load()) + } + if len(logged) != 1 || !strings.Contains(logged[0], "Downloading Node.js %s") { + t.Errorf("logged %v", logged) + } + root := filepath.Join(request.SessionDir, "runtime", name) + if info, err := os.Stat(filepath.Join(root, "bin", "node")); err != nil || info.Mode().Perm()&0o100 == 0 { + t.Errorf("node = %v, %v", info, err) + } + if _, err := os.Lstat(filepath.Join(root, "bin", "npm")); err == nil { + t.Error("unpacked a symbolic link") + } +} + +func TestDownloadNodeRejectsABadChecksum(t *testing.T) { + name, archive := fakeNodeBuild(t) + server, _ := serve(t, name, archive) + nodeChecksums[name+".tar.gz"] = strings.Repeat("0", 64) + withoutNode(t) + + adapter := &Adapter{NodeDist: server.URL, HTTP: server.Client()} + dir := t.TempDir() + _, err := adapter.CreateKey(context.Background(), apns.Request{Name: "Appwrite Push", Environment: apns.EnvironmentAll, SessionDir: dir}) + if err == nil || !strings.Contains(err.Error(), "does not match its checksum") { + t.Fatalf("err = %v", err) + } + if _, err := os.Stat(filepath.Join(dir, "runtime", name)); err == nil { + t.Error("unpacked a download that failed its checksum") + } +} + +func TestUntarRefusesPathsOutsideTheFolder(t *testing.T) { + parent := t.TempDir() + root := filepath.Join(parent, "root") + archive := filepath.Join(parent, "evil.tar.gz") + if err := os.WriteFile(archive, tarball(t, []entry{ + {name: "../evil", mode: 0o644, contents: "x"}, + }), 0o600); err != nil { + t.Fatal(err) + } + + if err := untar(archive, root); err == nil || !strings.Contains(err.Error(), "unsafe path") { + t.Errorf("err = %v", err) + } + if _, err := os.Stat(filepath.Join(parent, "evil")); err == nil { + t.Error("wrote outside the folder") + } +} + +func TestConcurrentFirstRunsShareOneRuntime(t *testing.T) { + name, archive := fakeNodeBuild(t) + server, _ := serve(t, name, archive) + dir := t.TempDir() + + const runs = 4 + errs := make(chan error, runs) + for range runs { + go func() { + adapter := &Adapter{NodeDist: server.URL, HTTP: server.Client()} + node, _, err := adapter.downloadNode(context.Background(), dir, func(string, ...any) {}) + if err == nil { + _, err = os.Stat(node) + } + errs <- err + }() + } + for range runs { + if err := <-errs; err != nil { + t.Error(err) + } + } + if _, err := os.Stat(filepath.Join(dir, name, "lib", "node_modules", "npm", "bin", "npm-cli.js")); err != nil { + t.Errorf("runtime is incomplete after concurrent runs: %v", err) + } + leftovers, _ := filepath.Glob(filepath.Join(dir, ".node-*")) + if len(leftovers) != 0 { + t.Errorf("left staging files behind: %v", leftovers) + } +} diff --git a/templates/cli/internal/apns/fastlane/adapter.go b/templates/cli/internal/apns/fastlane/adapter.go new file mode 100644 index 0000000000..9743caea1d --- /dev/null +++ b/templates/cli/internal/apns/fastlane/adapter.go @@ -0,0 +1,489 @@ +// Package fastlane is the APNs key setup built on fastlane's spaceship, the +// open-source Apple Developer portal client. +// +// It runs a lane from a Fastfile embedded in the binary with the fastlane the +// person already has installed and brings no Ruby of its own: the project's +// Bundler fastlane when its Gemfile lists one, otherwise fastlane from PATH +// (a gem or Homebrew installation). It needs fastlane 2.225.0 or +// later, the first release whose spaceship signs in with SRP. +package fastlane + +import ( + "bytes" + "context" + _ "embed" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "regexp" + "runtime" + "strconv" + "strings" + + "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/apns" +) + +//go:embed helper/Fastfile +var fastfile []byte + +// fastlane 2.225.0 (October 2024) is the first that signs in with SRP, which +// Apple has required since then; earlier versions get "503 Service +// Temporarily Unavailable" when signing in. +var minimumVersion = [3]int{2, 225, 0} + +// Adapter creates APNs keys, and prepares App IDs, with an installed fastlane. +type Adapter struct { + // Fastlane is the command to run. Empty means the project's Bundler + // fastlane when there is one, otherwise "fastlane" from PATH. + Fastlane string + // Bundle is Bundler's command. Empty means "bundle" from PATH. + Bundle string + // Brew is Homebrew's command, offered to install a missing fastlane. + // Empty means "brew" from PATH. + Brew string + // WorkDir is the project folder whose Gemfile may list fastlane. Empty + // means the current directory. + WorkDir string + // spaceship prompts for the two-factor code, and for a team when the + // account has several, on the terminal. Nil means the process's own. + Stdin io.Reader + Stdout io.Writer + Stderr io.Writer +} + +// New returns the adapter using fastlane from PATH and the terminal. +func New() *Adapter { + return &Adapter{} +} + +// Name is how people pick this setup. +func (a *Adapter) Name() string { + return "fastlane" +} + +type result struct { + KeyID string `json:"keyId"` + TeamID string `json:"teamId"` + P8 string `json:"p8"` + Error string `json:"error"` + Message string `json:"message"` + Keys []struct { + ID string `json:"id"` + Name string `json:"name"` + CanRevoke bool `json:"canRevoke"` + } `json:"keys"` + // The prepare_app_id lane's result. + Registered bool `json:"registered"` + Push bool `json:"push"` + Created bool `json:"created"` + PushTurnedOn bool `json:"pushTurnedOn"` +} + +// CreateKey runs the embedded create_apns_key lane. +func (a *Adapter) CreateKey(ctx context.Context, request apns.Request) (apns.Key, error) { + arguments := []string{"name:" + request.Name, "environment:" + string(request.Environment)} + contents, appleID, err := a.runLane(ctx, request, "create_apns_key", arguments...) + if err != nil { + return apns.Key{}, err + } + key, err := decodeResult(contents) + key.AppleID = appleID + + return key, err +} + +// PrepareAppID runs the embedded prepare_app_id lane: it checks the App ID, +// and after the question, or straight away without one, registers it and +// turns on Push Notifications. +func (a *Adapter) PrepareAppID(ctx context.Context, request apns.Request, appID apns.AppIDRequest) (apns.AppIDResult, error) { + // spaceship keeps its saved sign-in per Apple ID, so without one there is + // nothing to restore. + if request.SavedSignInOnly && strings.TrimSpace(request.AppleID) == "" { + return apns.AppIDResult{}, apns.ErrSignInNeeded + } + run := func(mode string) (apns.AppIDResult, error) { + arguments := []string{"mode:" + mode, "bundle_id:" + appID.BundleID, "name:" + apns.AppIDName(appID.BundleID)} + if request.SavedSignInOnly { + arguments = append(arguments, "saved_only:true") + } + contents, appleID, err := a.runLane(ctx, request, "prepare_app_id", arguments...) + if err != nil { + return apns.AppIDResult{}, err + } + // The apply run reuses the check's sign-in instead of asking again. + request.AppleID = appleID + + return decodeAppIDResult(contents) + } + if !appID.Ask { + return run("apply") + } + found, err := run("check") + if err != nil || (found.Registered && found.Push) { + return found, err + } + change, err := apns.ConfirmAppIDChange(request.Asker, appID.BundleID, found.TeamID, found.Registered) + if err != nil || !change { + return found, err + } + // The check signed in (again, for a reset) and saved the session, so the + // change reuses it instead of asking again. + request.Reset = false + request.TeamID = found.TeamID + request.SavedSignInOnly = true + + return run("apply") +} + +// runLane runs a lane of the embedded Fastfile and returns the result it +// wrote, with the Apple ID it ran as. The Apple ID and password are asked here when the request has none, +// and handed to fastlane as FASTLANE_USER and FASTLANE_PASSWORD, so fastlane +// never offers to keep them in the Keychain. With SavedSignInOnly nothing is +// asked: the lane uses spaceship's saved session for the Apple ID or reports +// that there is none. +func (a *Adapter) runLane(ctx context.Context, request apns.Request, lane string, arguments ...string) ([]byte, string, error) { + runner, err := a.runner() + if err != nil { + return nil, "", err + } + err = runner.check(ctx) + // Installing is only offered for fastlane from PATH, never replacing a + // configured command, and never during a check that must not ask. + if errors.Is(err, errNotInstalled) && a.Fastlane == "" && !request.SavedSignInOnly { + if err = a.offerInstall(ctx, request); err == nil { + err = runner.check(ctx) + } + } + if err != nil { + return nil, "", err + } + if runner.gemfile != "" && request.Log != nil { + request.Log("Using fastlane from %s with bundle exec.", runner.gemfile) + } + + appleID := strings.TrimSpace(request.AppleID) + if appleID == "" { + answer, err := request.Asker.Ask("Apple ID (email)", false) + if err != nil { + return nil, "", err + } + appleID = strings.TrimSpace(answer) + } + password := request.Password + if password == "" && !request.SavedSignInOnly { + answer, err := request.Asker.Ask("Password for "+appleID, true) + if err != nil { + return nil, "", err + } + password = answer + } + + // fastlane runs the Fastfile in ./fastlane of its working directory. + scratch, err := os.MkdirTemp("", "appwrite-apns-fastlane-") + if err != nil { + return nil, "", err + } + defer os.RemoveAll(scratch) + if err := os.MkdirAll(filepath.Join(scratch, "fastlane"), 0o700); err != nil { + return nil, "", err + } + if err := os.WriteFile(filepath.Join(scratch, "fastlane", "Fastfile"), fastfile, 0o600); err != nil { + return nil, "", err + } + out := filepath.Join(scratch, "result.json") + + arguments = append([]string{lane, "out:" + out}, arguments...) + if request.TeamID != "" { + arguments = append(arguments, "team_id:"+request.TeamID) + } + if request.Reset { + arguments = append(arguments, "reset:true") + } + command := runner.command(ctx, arguments...) + command.Dir = scratch + stdin, stdout, stderr := a.terminal() + // A saved sign-in asks nothing, so fastlane's log is shown only if the lane fails. + var log bytes.Buffer + if request.SavedSignInOnly { + stdout, stderr = &log, &log + } + command.Stdin, command.Stdout, command.Stderr = stdin, stdout, stderr + command.Env = append(command.Env, + "FASTLANE_USER="+appleID, + "FASTLANE_SKIP_UPDATE_CHECK=1", + "FASTLANE_OPT_OUT_USAGE=1", + "FASTLANE_HIDE_CHANGELOG=1", + ) + if password != "" { + command.Env = append(command.Env, "FASTLANE_PASSWORD="+password) + } + runErr := command.Run() + if runErr != nil && log.Len() > 0 { + _, _, terminalErr := a.terminal() + _, _ = log.WriteTo(terminalErr) + } + + contents, err := os.ReadFile(out) + if err != nil { + if runErr != nil { + return nil, "", fmt.Errorf("the fastlane lane failed: %w", runErr) + } + + return nil, "", fmt.Errorf("the fastlane lane returned no result: %w", err) + } + + return contents, appleID, nil +} + +func decodeAppIDResult(contents []byte) (apns.AppIDResult, error) { + var decoded result + if err := json.Unmarshal(contents, &decoded); err != nil { + return apns.AppIDResult{}, fmt.Errorf("the fastlane lane returned an unreadable result: %w", err) + } + if err := decoded.err(); err != nil { + return apns.AppIDResult{}, err + } + + return apns.AppIDResult{ + TeamID: decoded.TeamID, + Registered: decoded.Registered, + Push: decoded.Push, + Created: decoded.Created, + PushTurnedOn: decoded.PushTurnedOn, + }, nil +} + +// err is the error the lane reported, other than a full team. +func (r result) err() error { + switch r.Error { + case "": + return nil + case "sign-in-needed": + return apns.ErrSignInNeeded + case "invalid-credentials": + return fmt.Errorf("%w: %s", apns.ErrInvalidCredentials, r.Message) + default: + if r.Message == "" { + return errors.New(r.Error) + } + + return errors.New(r.Message) + } +} + +func decodeResult(contents []byte) (apns.Key, error) { + var decoded result + if err := json.Unmarshal(contents, &decoded); err != nil { + return apns.Key{}, fmt.Errorf("the fastlane lane returned an unreadable result: %w", err) + } + switch decoded.Error { + case "": + case "max-keys": + keys := make([]apns.ExistingKey, len(decoded.Keys)) + for index, key := range decoded.Keys { + keys[index] = apns.ExistingKey{ID: key.ID, Name: key.Name, CanRevoke: key.CanRevoke} + } + + return apns.Key{}, &apns.MaxKeysError{Keys: keys} + default: + return apns.Key{}, decoded.err() + } + if decoded.KeyID == "" || decoded.P8 == "" { + return apns.Key{}, errors.New("the fastlane lane returned no key") + } + + return apns.Key{KeyID: decoded.KeyID, TeamID: decoded.TeamID, P8: decoded.P8}, nil +} + +func (a *Adapter) terminal() (io.Reader, io.Writer, io.Writer) { + stdin, stdout, stderr := a.Stdin, a.Stdout, a.Stderr + if stdin == nil { + stdin = os.Stdin + } + if stdout == nil { + stdout = os.Stdout + } + if stderr == nil { + stderr = os.Stderr + } + + return stdin, stdout, stderr +} + +// runner is how fastlane is started: a command, the arguments before the +// lane's, and the environment it needs. +type runner struct { + name string + prefix []string + env []string + gemfile string +} + +var gemfileFastlane = regexp.MustCompile(`(?m)^\s*gem\s+['"]fastlane['"]`) + +// runner picks the configured command, then the project's Bundler fastlane, +// then fastlane from PATH. A Bundler fastlane must run with bundle exec and +// the project's Gemfile, since the lane runs in a temporary folder. When the +// project pins fastlane but Bundler is missing, it stops rather than run a +// fastlane other than the pinned one. +func (a *Adapter) runner() (runner, error) { + base := append(os.Environ(), "FASTLANE_SKIP_UPDATE_CHECK=1", "FASTLANE_OPT_OUT_USAGE=1") + if a.Fastlane != "" { + return runner{name: a.Fastlane, env: base}, nil + } + bundle := a.Bundle + if bundle == "" { + bundle = "bundle" + } + if gemfile := projectGemfile(a.WorkDir); gemfile != "" { + if _, err := exec.LookPath(bundle); err != nil { + return runner{}, fmt.Errorf("%w: %s pins fastlane, but Bundler is not installed. Install it with gem install bundler, then run bundle install there", apns.ErrUnavailable, gemfile) + } + + return runner{name: bundle, prefix: []string{"exec", "fastlane"}, env: append(base, "BUNDLE_GEMFILE="+gemfile), gemfile: gemfile}, nil + } + + return runner{name: "fastlane", env: base}, nil +} + +// projectGemfile returns the Gemfile in dir, or in dir/ios as Flutter and +// React Native projects keep it, when it lists fastlane. +func projectGemfile(dir string) string { + if dir == "" { + dir, _ = os.Getwd() + } + for _, candidate := range []string{filepath.Join(dir, "Gemfile"), filepath.Join(dir, "ios", "Gemfile")} { + contents, err := os.ReadFile(candidate) + if err != nil || !gemfileFastlane.Match(contents) { + continue + } + if absolute, err := filepath.Abs(candidate); err == nil { + return absolute + } + } + + return "" +} + +func (r runner) command(ctx context.Context, arguments ...string) *exec.Cmd { + command := exec.CommandContext(ctx, r.name, append(append([]string{}, r.prefix...), arguments...)...) + command.Env = append([]string{}, r.env...) + + return command +} + +// errNotInstalled means there is no fastlane on PATH, which installing one +// can fix. +var errNotInstalled = errors.New("fastlane is not installed") + +func (r runner) check(ctx context.Context) error { + if r.gemfile == "" && r.name == "fastlane" { + if _, err := exec.LookPath(r.name); err != nil { + return fmt.Errorf("%w: %w. %s", apns.ErrUnavailable, errNotInstalled, installHint) + } + } + output, err := r.command(ctx, "--version").Output() + if err != nil { + if r.gemfile != "" { + return fmt.Errorf("%w: bundle exec fastlane failed with %s. Run bundle install there first", apns.ErrUnavailable, r.gemfile) + } + + return fmt.Errorf("%w: %s --version failed: %v", apns.ErrUnavailable, r.name, err) + } + upgrade := "brew upgrade fastlane, or gem update fastlane, depending on how it was installed" + if r.gemfile != "" { + upgrade = bundlerUpgrade(filepath.Dir(r.gemfile), runtime.GOOS) + } + + return supportedVersion(string(output), upgrade) +} + +// bundlerUpgrade says how to upgrade a project's Bundler fastlane. On Unix +// it is one command to paste, with the folder quoted for a POSIX shell. On +// Windows no quoting is safe in every shell (PowerShell expands $ inside +// double quotes, cmd expands %, and Windows PowerShell has no &&), so the +// folder is named rather than put in a command. +func bundlerUpgrade(folder, goos string) string { + if goos == "windows" { + return "bundle update fastlane, run from the folder " + folder + } + + return "cd " + shellQuote(folder) + " && bundle update fastlane" +} + +// shellQuote quotes path for a POSIX shell: in single quotes, with any single +// quote closed, escaped and reopened. A path of only safe characters is left +// as it is. +func shellQuote(path string) string { + if path != "" && strings.Trim(path, "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789/._-") == "" { + return path + } + + return "'" + strings.ReplaceAll(path, "'", `'\''`) + "'" +} + +const installHint = "Install it with brew install fastlane (Homebrew) or gem install fastlane, " + + "or add gem \"fastlane\" to your project's Gemfile and run bundle install. " + + "Or use --provider appwrite, which needs nothing installed" + +// offerInstall asks to install fastlane with Homebrew, when there is one, and +// runs it on the terminal. Without Homebrew, a terminal or a yes, it returns +// how to install fastlane instead. +func (a *Adapter) offerInstall(ctx context.Context, request apns.Request) error { + notInstalled := fmt.Errorf("%w: %w. %s", apns.ErrUnavailable, errNotInstalled, installHint) + brew := a.Brew + if brew == "" { + brew = "brew" + } + if _, err := exec.LookPath(brew); err != nil || request.Asker == nil { + return notInstalled + } + choice, err := request.Asker.Choose("fastlane is not installed. Install it with Homebrew now?", + []string{"Yes, run brew install fastlane", "No"}) + if err != nil || choice != 0 { + return notInstalled + } + + if request.Log != nil { + request.Log("Running brew install fastlane ...") + } + command := exec.CommandContext(ctx, brew, "install", "fastlane") + command.Stdin, command.Stdout, command.Stderr = a.terminal() + if err := command.Run(); err != nil { + return fmt.Errorf("%w: brew install fastlane failed: %v. %s", apns.ErrUnavailable, err, installHint) + } + + return nil +} + +var versionPattern = regexp.MustCompile(`fastlane (\d+)\.(\d+)\.(\d+)`) + +func supportedVersion(output, upgrade string) error { + match := versionPattern.FindStringSubmatch(output) + if match == nil { + return fmt.Errorf("%w: could not read the fastlane version", apns.ErrUnavailable) + } + var version [3]int + for index := range version { + version[index], _ = strconv.Atoi(match[index+1]) + } + for index := range version { + if version[index] != minimumVersion[index] { + if version[index] < minimumVersion[index] { + return fmt.Errorf("%w: the fastlane setup needs fastlane %s or later, found %s. Upgrade it with %s", apns.ErrUnavailable, versionString(minimumVersion), versionString(version), upgrade) + } + + break + } + } + + return nil +} + +func versionString(version [3]int) string { + return fmt.Sprintf("%d.%d.%d", version[0], version[1], version[2]) +} diff --git a/templates/cli/internal/apns/fastlane/adapter_test.go b/templates/cli/internal/apns/fastlane/adapter_test.go new file mode 100644 index 0000000000..0ba41ab71d --- /dev/null +++ b/templates/cli/internal/apns/fastlane/adapter_test.go @@ -0,0 +1,542 @@ +package fastlane + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/apns" +) + +func TestSupportedVersion(t *testing.T) { + for output, supported := range map[string]bool{ + "fastlane installation at path:\n/opt/homebrew/bin/fastlane\n-----------------------------\nfastlane 2.228.0\n": true, + "fastlane 2.225.0": true, + "fastlane 3.0.0": true, + "fastlane 2.224.9": false, + "fastlane 1.300.0": false, + "something else": false, + } { + err := supportedVersion(output, "brew upgrade fastlane") + if (err == nil) != supported { + t.Errorf("supportedVersion(%q) = %v", output, err) + } + if err != nil && !errors.Is(err, apns.ErrUnavailable) { + t.Errorf("supportedVersion(%q) is not ErrUnavailable: %v", output, err) + } + } +} + +func TestDecodeResult(t *testing.T) { + key, err := decodeResult([]byte(`{"keyId":"KEY1234567","teamId":"ABCDE12345","p8":"-----BEGIN PRIVATE KEY-----"}`)) + if err != nil || key.KeyID != "KEY1234567" || key.TeamID != "ABCDE12345" { + t.Errorf("key = %+v, %v", key, err) + } + + _, err = decodeResult([]byte(`{"error":"max-keys","keys":[{"id":"OLD1234567","name":"Old","canRevoke":true}]}`)) + var maxKeys *apns.MaxKeysError + if !errors.As(err, &maxKeys) || len(maxKeys.Keys) != 1 || maxKeys.Keys[0].ID != "OLD1234567" || !maxKeys.Keys[0].CanRevoke { + t.Errorf("max keys = %v", err) + } + + _, err = decodeResult([]byte(`{"error":"invalid-credentials","message":"Invalid username and password combination"}`)) + if !errors.Is(err, apns.ErrInvalidCredentials) { + t.Errorf("credentials = %v", err) + } + + if _, err := decodeResult([]byte(`{"error":"failed","message":"Apple is down"}`)); err == nil || err.Error() != "Apple is down" { + t.Errorf("failure = %v", err) + } + if _, err := decodeResult([]byte(`{}`)); err == nil { + t.Error("an empty result was accepted") + } +} + +// fakeFastlane writes a fastlane stand-in that answers --version with version +// and, for the lane, logs its arguments and environment, checks the Fastfile +// is in place and writes result to the out: file. +func fakeFastlane(t *testing.T, version, result string) (string, string) { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("shell stand-in") + } + bin := t.TempDir() + log := filepath.Join(bin, "log") + script := `#!/bin/sh +if [ "$1" = "--version" ]; then printf 'fastlane installation at path:\n/usr/local/bin/fastlane\nfastlane ` + version + `\n'; exit 0; fi +test -f fastlane/Fastfile || { echo "no Fastfile" >> "` + log + `"; exit 1; } +password=unset +[ -n "$FASTLANE_PASSWORD" ] && password=set +echo "fastlane $* FASTLANE_USER=$FASTLANE_USER password=$password" >> "` + log + `" +for argument in "$@"; do + case "$argument" in + out:*) printf '%s' '` + result + `' > "${argument#out:}" ;; + esac +done +` + path := filepath.Join(bin, "fastlane") + if err := os.WriteFile(path, []byte(script), 0o700); err != nil { + t.Fatal(err) + } + + return path, log +} + +type scriptedAsker struct { + answers map[string]string + asked []string +} + +func (s *scriptedAsker) Ask(question string, secret bool) (string, error) { + s.asked = append(s.asked, question) + for prefix, answer := range s.answers { + if strings.HasPrefix(question, prefix) { + return answer, nil + } + } + + return "", errors.New("no answer for " + question) +} + +func (s *scriptedAsker) Choose(string, []string) (int, error) { + return 0, errors.New("not expected") +} + +func TestCreateKeyRunsTheLane(t *testing.T) { + fastlane, log := fakeFastlane(t, "2.228.0", `{"keyId":"KEY1234567","teamId":"ABCDE12345","p8":"pem"}`) + adapter := &Adapter{Fastlane: fastlane, Stdout: &strings.Builder{}, Stderr: &strings.Builder{}} + asker := &scriptedAsker{answers: map[string]string{"Apple ID": "dev@example.com", "Password for": "secret"}} + + key, err := adapter.CreateKey(context.Background(), apns.Request{ + TeamID: "ABCDE12345", + Name: "Appwrite Push", + Environment: apns.EnvironmentSandbox, + Asker: asker, + }) + if err != nil || key.KeyID != "KEY1234567" || key.P8 != "pem" || key.AppleID != "dev@example.com" { + t.Fatalf("key = %+v, %v", key, err) + } + if strings.Join(asker.asked, "|") != "Apple ID (email)|Password for dev@example.com" { + t.Errorf("asked %v", asker.asked) + } + calls, _ := os.ReadFile(log) + call := strings.TrimSpace(string(calls)) + if !strings.HasPrefix(call, "fastlane create_apns_key out:") || + !strings.HasSuffix(call, "name:Appwrite Push environment:sandbox team_id:ABCDE12345 FASTLANE_USER=dev@example.com password=set") { + t.Errorf("call = %s", call) + } +} + +func TestCreateKeyUsesTheCredentialsInTheRequest(t *testing.T) { + fastlane, _ := fakeFastlane(t, "2.228.0", `{"keyId":"KEY1234567","teamId":"ABCDE12345","p8":"pem"}`) + adapter := &Adapter{Fastlane: fastlane, Stdout: &strings.Builder{}, Stderr: &strings.Builder{}} + asker := &scriptedAsker{} + + _, err := adapter.CreateKey(context.Background(), apns.Request{ + Name: "Appwrite Push", Environment: apns.EnvironmentAll, AppleID: "dev@example.com", Password: "secret", Asker: asker, + }) + if err != nil { + t.Fatal(err) + } + if len(asker.asked) != 0 { + t.Errorf("asked %v", asker.asked) + } +} + +func TestCreateKeyNeedsARecentFastlane(t *testing.T) { + old, log := fakeFastlane(t, "2.220.0", `{}`) + request := apns.Request{Name: "Appwrite Push", Environment: apns.EnvironmentAll, Asker: &scriptedAsker{}} + + if _, err := (&Adapter{Fastlane: old}).CreateKey(context.Background(), request); !errors.Is(err, apns.ErrUnavailable) || + !strings.Contains(err.Error(), "found 2.220.0") { + t.Fatalf("err = %v", err) + } + if _, err := os.Stat(log); err == nil { + t.Error("ran the lane with an old fastlane") + } + + missing := &Adapter{Fastlane: filepath.Join(t.TempDir(), "fastlane")} + if _, err := missing.CreateKey(context.Background(), request); !errors.Is(err, apns.ErrUnavailable) { + t.Fatalf("missing fastlane err = %v", err) + } +} + +func TestProjectGemfile(t *testing.T) { + root := t.TempDir() + if gemfile := projectGemfile(root); gemfile != "" { + t.Errorf("found %s in an empty folder", gemfile) + } + + if err := os.WriteFile(filepath.Join(root, "Gemfile"), []byte("source \"https://rubygems.org\"\ngem \"cocoapods\"\n"), 0o600); err != nil { + t.Fatal(err) + } + if gemfile := projectGemfile(root); gemfile != "" { + t.Errorf("used a Gemfile without fastlane: %s", gemfile) + } + + if err := os.MkdirAll(filepath.Join(root, "ios"), 0o700); err != nil { + t.Fatal(err) + } + ios := filepath.Join(root, "ios", "Gemfile") + if err := os.WriteFile(ios, []byte("source 'https://rubygems.org'\n\n gem 'fastlane', '~> 2.228'\n"), 0o600); err != nil { + t.Fatal(err) + } + if gemfile := projectGemfile(root); gemfile != ios { + t.Errorf("projectGemfile = %q, want %q", gemfile, ios) + } +} + +func TestCreateKeyRunsTheProjectsBundlerFastlane(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("shell stand-in") + } + project := t.TempDir() + gemfile := filepath.Join(project, "Gemfile") + if err := os.WriteFile(gemfile, []byte("gem \"fastlane\"\n"), 0o600); err != nil { + t.Fatal(err) + } + bin := t.TempDir() + log := filepath.Join(bin, "log") + bundle := filepath.Join(bin, "bundle") + script := `#!/bin/sh +echo "bundle $* BUNDLE_GEMFILE=$BUNDLE_GEMFILE" >> "` + log + `" +[ "$1 $2" = "exec fastlane" ] || exit 1 +if [ "$3" = "--version" ]; then echo "fastlane 2.228.0"; exit 0; fi +test -f fastlane/Fastfile || exit 1 +for argument in "$@"; do + case "$argument" in + out:*) printf '%s' '{"keyId":"KEY1234567","teamId":"ABCDE12345","p8":"pem"}' > "${argument#out:}" ;; + esac +done +` + if err := os.WriteFile(bundle, []byte(script), 0o700); err != nil { + t.Fatal(err) + } + adapter := &Adapter{Bundle: bundle, WorkDir: project, Stdout: &strings.Builder{}, Stderr: &strings.Builder{}} + var logged []string + request := apns.Request{ + Name: "Appwrite Push", Environment: apns.EnvironmentAll, AppleID: "dev@example.com", Password: "secret", + Log: func(format string, args ...any) { logged = append(logged, fmt.Sprintf(format, args...)) }, + } + + if _, err := adapter.CreateKey(context.Background(), request); err != nil { + t.Fatal(err) + } + calls, _ := os.ReadFile(log) + lines := strings.Split(strings.TrimSpace(string(calls)), "\n") + if len(lines) != 2 || lines[0] != "bundle exec fastlane --version BUNDLE_GEMFILE="+gemfile || + !strings.HasPrefix(lines[1], "bundle exec fastlane create_apns_key out:") || !strings.HasSuffix(lines[1], "BUNDLE_GEMFILE="+gemfile) { + t.Errorf("calls:\n%s", calls) + } + if len(logged) != 1 || logged[0] != "Using fastlane from "+gemfile+" with bundle exec." { + t.Errorf("logged %v", logged) + } +} + +func TestCreateKeyWillNotBypassAPinnedFastlane(t *testing.T) { + project := t.TempDir() + gemfile := filepath.Join(project, "Gemfile") + if err := os.WriteFile(gemfile, []byte("gem \"fastlane\"\n"), 0o600); err != nil { + t.Fatal(err) + } + adapter := &Adapter{Bundle: filepath.Join(t.TempDir(), "bundle"), WorkDir: project} + + _, err := adapter.CreateKey(context.Background(), apns.Request{Name: "Appwrite Push", Environment: apns.EnvironmentAll, AppleID: "dev@example.com", Password: "secret"}) + if !errors.Is(err, apns.ErrUnavailable) || !strings.Contains(err.Error(), gemfile+" pins fastlane, but Bundler is not installed") { + t.Fatalf("err = %v", err) + } +} + +// choosingAsker answers every choice with choice and records the questions. +type choosingAsker struct { + choice int + asked []string +} + +func (c *choosingAsker) Ask(question string, secret bool) (string, error) { + return "", errors.New("not expected: " + question) +} + +func (c *choosingAsker) Choose(question string, options []string) (int, error) { + c.asked = append(c.asked, question) + + return c.choice, nil +} + +// withoutFastlane leaves PATH with only bin, so fastlane is not found unless +// something puts it there. +func withoutFastlane(t *testing.T) string { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("shell stand-ins") + } + bin := t.TempDir() + t.Setenv("PATH", bin) + + return bin +} + +// fakeBrew writes a brew stand-in into bin that logs its arguments and, for +// install fastlane, copies fastlane into bin. +func fakeBrew(t *testing.T, bin, fastlane string) string { + t.Helper() + log := filepath.Join(t.TempDir(), "brew.log") + script := `#!/bin/sh +echo "brew $*" >> "` + log + `" +[ "$1 $2" = "install fastlane" ] && /bin/cp "` + fastlane + `" "` + bin + `/fastlane" +` + if err := os.WriteFile(filepath.Join(bin, "brew"), []byte(script), 0o700); err != nil { + t.Fatal(err) + } + + return log +} + +func TestCreateKeyExplainsHowToInstallFastlane(t *testing.T) { + withoutFastlane(t) + request := apns.Request{Name: "Appwrite Push", Environment: apns.EnvironmentAll, Asker: &choosingAsker{}} + + _, err := (&Adapter{}).CreateKey(context.Background(), request) + if !errors.Is(err, apns.ErrUnavailable) { + t.Fatalf("err = %v", err) + } + for _, part := range []string{"fastlane is not installed", "brew install fastlane", "gem install fastlane", "--provider appwrite"} { + if !strings.Contains(err.Error(), part) { + t.Errorf("message is missing %q: %v", part, err) + } + } +} + +func TestCreateKeyInstallsFastlaneWithHomebrew(t *testing.T) { + fastlane, _ := fakeFastlane(t, "2.228.0", `{"keyId":"KEY1234567","teamId":"ABCDE12345","p8":"pem"}`) + bin := withoutFastlane(t) + log := fakeBrew(t, bin, fastlane) + asker := &choosingAsker{choice: 0} + var logged []string + request := apns.Request{ + Name: "Appwrite Push", Environment: apns.EnvironmentAll, AppleID: "dev@example.com", Password: "secret", Asker: asker, + Log: func(format string, args ...any) { logged = append(logged, format) }, + } + + key, err := (&Adapter{Stdout: &strings.Builder{}, Stderr: &strings.Builder{}}).CreateKey(context.Background(), request) + if err != nil || key.KeyID != "KEY1234567" { + t.Fatalf("key = %+v, %v", key, err) + } + if strings.Join(asker.asked, "|") != "fastlane is not installed. Install it with Homebrew now?" { + t.Errorf("asked %v", asker.asked) + } + if calls, _ := os.ReadFile(log); strings.TrimSpace(string(calls)) != "brew install fastlane" { + t.Errorf("brew calls = %q", calls) + } + if len(logged) != 1 || logged[0] != "Running brew install fastlane ..." { + t.Errorf("logged %v", logged) + } +} + +func TestCreateKeyDoesNotInstallFastlaneWhenDeclined(t *testing.T) { + fastlane, _ := fakeFastlane(t, "2.228.0", `{}`) + bin := withoutFastlane(t) + log := fakeBrew(t, bin, fastlane) + + _, err := (&Adapter{}).CreateKey(context.Background(), apns.Request{Name: "Appwrite Push", Environment: apns.EnvironmentAll, Asker: &choosingAsker{choice: 1}}) + if !errors.Is(err, apns.ErrUnavailable) || !strings.Contains(err.Error(), "brew install fastlane") { + t.Fatalf("err = %v", err) + } + if _, err := os.Stat(log); err == nil { + t.Error("ran brew after the offer was declined") + } +} + +func TestOldFastlaneSaysHowToUpgrade(t *testing.T) { + if err := supportedVersion("fastlane 2.220.0", "cd /app && bundle update fastlane"); err == nil || + !strings.Contains(err.Error(), "found 2.220.0. Upgrade it with cd /app && bundle update fastlane") { + t.Errorf("err = %v", err) + } +} + +func TestAConfiguredFastlaneIsNeverInstalled(t *testing.T) { + fastlane, _ := fakeFastlane(t, "2.228.0", `{}`) + bin := withoutFastlane(t) + log := fakeBrew(t, bin, fastlane) + asker := &choosingAsker{choice: 0} + + _, err := (&Adapter{Fastlane: "fastlane"}).CreateKey(context.Background(), apns.Request{Name: "Appwrite Push", Environment: apns.EnvironmentAll, Asker: asker}) + if !errors.Is(err, apns.ErrUnavailable) { + t.Fatalf("err = %v", err) + } + if len(asker.asked) != 0 { + t.Errorf("offered to install: %v", asker.asked) + } + if _, err := os.Stat(log); err == nil { + t.Error("ran brew for a configured command") + } +} + +func TestTheBundlerUpgradeCommandRuns(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("shell stand-in") + } + project := t.TempDir() + gemfile := filepath.Join(project, "Gemfile") + if err := os.WriteFile(gemfile, []byte("gem \"fastlane\"\n"), 0o600); err != nil { + t.Fatal(err) + } + bundle := filepath.Join(t.TempDir(), "bundle") + if err := os.WriteFile(bundle, []byte("#!/bin/sh\necho \"fastlane 2.220.0\"\n"), 0o700); err != nil { + t.Fatal(err) + } + + _, err := (&Adapter{Bundle: bundle, WorkDir: project}).CreateKey(context.Background(), apns.Request{Name: "Appwrite Push", Environment: apns.EnvironmentAll, AppleID: "dev@example.com", Password: "secret"}) + if err == nil || !strings.Contains(err.Error(), "Upgrade it with cd "+project+" && bundle update fastlane") { + t.Errorf("err = %v", err) + } +} + +func TestBundlerUpgrade(t *testing.T) { + for _, test := range []struct{ folder, goos, want string }{ + {"/Users/dev/app", "darwin", "cd /Users/dev/app && bundle update fastlane"}, + {"/Users/dev/My App", "darwin", "cd '/Users/dev/My App' && bundle update fastlane"}, + {"/Users/dev/it's", "linux", `cd '/Users/dev/it'\''s' && bundle update fastlane`}, + {`/Users/dev/back\slash`, "linux", `cd '/Users/dev/back\slash' && bundle update fastlane`}, + {`/Users/dev/$(whoami)`, "linux", `cd '/Users/dev/$(whoami)' && bundle update fastlane`}, + {`C:\Users\dev\$(Get-Date) App`, "windows", `bundle update fastlane, run from the folder C:\Users\dev\$(Get-Date) App`}, + } { + if got := bundlerUpgrade(test.folder, test.goos); got != test.want { + t.Errorf("bundlerUpgrade(%q, %s) = %s, want %s", test.folder, test.goos, got, test.want) + } + } +} + +// The quoted command must work when pasted into a shell, for a folder whose +// name has spaces and a quote. +func TestTheQuotedCdRunsInAShell(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX shell") + } + folder := filepath.Join(t.TempDir(), "My Project's App") + if err := os.Mkdir(folder, 0o700); err != nil { + t.Fatal(err) + } + output, err := exec.Command("/bin/sh", "-c", "cd "+shellQuote(folder)+" && pwd -P").Output() + resolved, _ := filepath.EvalSymlinks(folder) + if err != nil || strings.TrimSpace(string(output)) != resolved { + t.Errorf("cd landed in %q, %v; want %q", output, err, resolved) + } +} + +func TestCreateKeyPassesResetToTheLane(t *testing.T) { + fastlane, log := fakeFastlane(t, "2.228.0", `{"keyId":"KEY1234567","teamId":"ABCDE12345","p8":"pem"}`) + adapter := &Adapter{Fastlane: fastlane, Stdout: &strings.Builder{}, Stderr: &strings.Builder{}} + request := apns.Request{Name: "Appwrite Push", Environment: apns.EnvironmentAll, AppleID: "dev@example.com", Password: "secret", Reset: true} + + if _, err := adapter.CreateKey(context.Background(), request); err != nil { + t.Fatal(err) + } + if calls, _ := os.ReadFile(log); !strings.Contains(string(calls), " reset:true ") { + t.Errorf("lane call = %s", calls) + } +} + +func laneCalls(t *testing.T, log string) []string { + t.Helper() + calls, _ := os.ReadFile(log) + text := strings.TrimSpace(string(calls)) + if text == "" { + return nil + } + + return strings.Split(text, "\n") +} + +func TestPrepareAppIDWithoutAskingAppliesInOneRun(t *testing.T) { + fastlane, log := fakeFastlane(t, "2.228.0", `{"teamId":"ABCDE12345","registered":true,"push":true,"created":true,"pushTurnedOn":true}`) + adapter := &Adapter{Fastlane: fastlane, Stdout: &strings.Builder{}, Stderr: &strings.Builder{}} + request := apns.Request{TeamID: "ABCDE12345", AppleID: "dev@example.com", Password: "secret", Asker: &choosingAsker{}} + + result, err := adapter.PrepareAppID(context.Background(), request, apns.AppIDRequest{BundleID: "io.example.app"}) + if err != nil || result != (apns.AppIDResult{TeamID: "ABCDE12345", Registered: true, Push: true, Created: true, PushTurnedOn: true}) { + t.Fatalf("result = %+v, %v", result, err) + } + calls := laneCalls(t, log) + if len(calls) != 1 || !strings.HasPrefix(calls[0], "fastlane prepare_app_id out:") || + !strings.HasSuffix(calls[0], "mode:apply bundle_id:io.example.app name:io example app team_id:ABCDE12345 FASTLANE_USER=dev@example.com password=set") { + t.Errorf("lane calls = %v", calls) + } +} + +func TestPrepareAppIDOnlyChecksAReadyAppID(t *testing.T) { + fastlane, log := fakeFastlane(t, "2.228.0", `{"teamId":"ABCDE12345","registered":true,"push":true}`) + adapter := &Adapter{Fastlane: fastlane, Stdout: &strings.Builder{}, Stderr: &strings.Builder{}} + asker := &choosingAsker{} + request := apns.Request{AppleID: "dev@example.com", Password: "secret", Asker: asker} + + result, err := adapter.PrepareAppID(context.Background(), request, apns.AppIDRequest{BundleID: "io.example.app", Ask: true}) + if err != nil || !result.Registered || !result.Push { + t.Fatalf("result = %+v, %v", result, err) + } + if calls := laneCalls(t, log); len(calls) != 1 || !strings.Contains(calls[0], "mode:check") || len(asker.asked) != 0 { + t.Errorf("lane calls = %v, asked %v", calls, asker.asked) + } +} + +func TestPrepareAppIDAsksBeforeApplying(t *testing.T) { + fastlane, log := fakeFastlane(t, "2.228.0", `{"teamId":"ABCDE12345","registered":true,"push":false}`) + adapter := &Adapter{Fastlane: fastlane, Stdout: &strings.Builder{}, Stderr: &strings.Builder{}} + asker := &choosingAsker{} + request := apns.Request{AppleID: "dev@example.com", Password: "secret", Asker: asker, Reset: true} + + if _, err := adapter.PrepareAppID(context.Background(), request, apns.AppIDRequest{BundleID: "io.example.app", Ask: true}); err != nil { + t.Fatal(err) + } + calls := laneCalls(t, log) + if len(calls) != 2 || !strings.Contains(calls[0], "mode:check") || !strings.Contains(calls[0], "reset:true") || + !strings.Contains(calls[1], "mode:apply") || !strings.Contains(calls[1], "team_id:ABCDE12345") || strings.Contains(calls[1], "reset:true") || + !strings.Contains(calls[1], "saved_only:true") { + t.Errorf("lane calls = %v", calls) + } + if strings.Join(asker.asked, "|") != "Push Notifications is off for io.example.app, so APNs refuses pushes for it. Turn it on?" { + t.Errorf("asked %v", asker.asked) + } +} + +func TestPrepareAppIDWithTheSavedSignInOnly(t *testing.T) { + fastlane, log := fakeFastlane(t, "2.228.0", `{"error":"sign-in-needed"}`) + adapter := &Adapter{Fastlane: fastlane, Stdout: &strings.Builder{}, Stderr: &strings.Builder{}} + request := apns.Request{SavedSignInOnly: true, Asker: &choosingAsker{}} + + // spaceship keeps sessions per Apple ID: without one there is none. + if _, err := adapter.PrepareAppID(context.Background(), request, apns.AppIDRequest{BundleID: "io.example.app", Ask: true}); !errors.Is(err, apns.ErrSignInNeeded) { + t.Fatalf("err = %v", err) + } + if calls := laneCalls(t, log); len(calls) != 0 { + t.Fatalf("ran the lane without an Apple ID: %v", calls) + } + + // With one, the lane checks the saved session; no password is asked. + request.AppleID = "dev@example.com" + if _, err := adapter.PrepareAppID(context.Background(), request, apns.AppIDRequest{BundleID: "io.example.app", Ask: true}); !errors.Is(err, apns.ErrSignInNeeded) { + t.Fatalf("err = %v", err) + } + if calls := laneCalls(t, log); len(calls) != 1 || !strings.Contains(calls[0], "saved_only:true") || !strings.HasSuffix(calls[0], "password=unset") { + t.Errorf("lane calls = %v", calls) + } +} + +func TestASavedSignInCheckNeverOffersToInstallFastlane(t *testing.T) { + bin := withoutFastlane(t) + fastlane, _ := fakeFastlane(t, "2.228.0", `{}`) + fakeBrew(t, bin, fastlane) + asker := &choosingAsker{} + request := apns.Request{AppleID: "dev@example.com", SavedSignInOnly: true, Asker: asker} + + _, err := (&Adapter{}).PrepareAppID(context.Background(), request, apns.AppIDRequest{BundleID: "io.example.app", Ask: true}) + if !errors.Is(err, apns.ErrUnavailable) || len(asker.asked) != 0 { + t.Errorf("err = %v, asked %v", err, asker.asked) + } +} diff --git a/templates/cli/internal/apns/fastlane/helper/Fastfile b/templates/cli/internal/apns/fastlane/helper/Fastfile new file mode 100644 index 0000000000..6deaaf0d83 --- /dev/null +++ b/templates/cli/internal/apns/fastlane/helper/Fastfile @@ -0,0 +1,114 @@ +# Creates a team-scoped APNs key, or prepares the app's App ID, for +# `appwrite init apns --provider fastlane` with spaceship, fastlane's Apple +# Developer portal client, so any fastlane installation (gem, Homebrew or +# bundler) can run it. +# +# spaceship asks for the two-factor code, and for a team when the account has +# several, on the terminal. The Apple ID and password come from FASTLANE_USER +# and FASTLANE_PASSWORD. fastlane logs to stdout, so the result is written as +# JSON to the file named by out: instead. +require 'json' + +lane :create_apns_key do |options| + write_result(options) { create_apns_key_with_spaceship(options) } +end + +lane :prepare_app_id do |options| + write_result(options) { prepare_app_id_with_spaceship(options) } +end + +def write_result(options) + result = begin + yield + rescue StandardError => error + kind = error.kind_of?(Spaceship::InvalidUserCredentialsError) ? 'invalid-credentials' : 'failed' + { error: kind, message: error.message } + end + File.open(options[:out], 'w', 0o600) { |file| file.write(JSON.generate(result)) } +end + +# sign_in returns the portal client for the team, or nil when saved_only:true +# finds no saved sign-in for FASTLANE_USER. +def sign_in(options) + require 'spaceship' + + user = ENV.fetch('FASTLANE_USER', nil) + # reset:true deletes spaceship's saved session for this Apple ID first, so + # the sign-in starts over, two-factor included. + if options[:reset].to_s == 'true' + cookie = Spaceship::PortalClient.new.tap { |client| client.user = user }.persistent_cookie_path + File.delete(cookie) if cookie && File.exist?(cookie) + end + if options[:saved_only].to_s == 'true' + client = Spaceship::PortalClient.new + client.user = user + return nil unless client.has_valid_session + + Spaceship::Portal.client = client + else + Spaceship::Portal.login(user, ENV.fetch('FASTLANE_PASSWORD', nil)) + end + team_id = options[:team_id].to_s + if team_id.empty? + Spaceship::Portal.select_team + else + Spaceship::Portal.select_team(team_id: team_id) + end + Spaceship::Portal.client +end + +def create_apns_key_with_spaceship(options) + client = sign_in(options) + + # Key.create always asks for every environment, so the client is called + # directly with the one requested. + environment = options[:environment].to_s.empty? ? 'all' : options[:environment].to_s + begin + created = client.create_key!( + name: options[:name], + service_configs: { + Spaceship::Portal::Key::APNS_ID => { scope: 'team', environment: environment, identifiers: {} } + } + ) + rescue StandardError => error + # Worded with or without "team scoped" before "Keys". + raise unless error.message =~ /maximum allowed number of (?:[a-z ]+ )?keys/i + + keys = client.list_keys.map do |key| + { id: key['keyId'], name: key['keyName'], canRevoke: key['canRevoke'] == true } + end + return { error: 'max-keys', keys: keys } + end + + key_id = created['keyId'] + { keyId: key_id, teamId: client.team_id, p8: client.download_key(id: key_id) } +end + +# Reports whether bundle_id: is an App ID with Push Notifications, and with +# mode:apply registers it and turns push on. +def prepare_app_id_with_spaceship(options) + client = sign_in(options) + return { error: 'sign-in-needed' } unless client + + push_type = Spaceship::ConnectAPI::BundleIdCapability::Type::PUSH_NOTIFICATIONS + Spaceship::ConnectAPI.client = Spaceship::ConnectAPI::Client.new(portal_client: client) + identifier = options[:bundle_id].to_s + bundle = Spaceship::ConnectAPI::BundleId.find(identifier, includes: 'bundleIdCapabilities') + push = !bundle.nil? && (bundle.bundle_id_capabilities || []).any? { |capability| capability.is_type?(push_type) } + result = { teamId: client.team_id, registered: !bundle.nil?, push: push, created: false, pushTurnedOn: false } + return result unless options[:mode].to_s == 'apply' + + if bundle.nil? + bundle = Spaceship::ConnectAPI::BundleId.create( + name: options[:name].to_s, platform: 'IOS', identifier: identifier, seed_id: client.team_id + ) + result[:registered] = true + result[:created] = true + end + unless result[:push] + bundle.update_capability(push_type, enabled: true) + result[:push] = true + result[:pushTurnedOn] = true + end + result +end From 063beaa24c60ee7b6692cbf1af4ebfb8c934e1cd Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Tue, 13 Oct 2026 15:56:51 +0530 Subject: [PATCH 2/9] feat(cli): appwrite init apns Sets up the APNs providers (sandbox and production) for an iOS app from an existing .p8 or one created by signing in with the Apple ID, detects the bundle ID and team from an Xcode project or Expo config, adds aps-environment and the remote-notification background mode, and checks the App ID on every run. --- src/SDK/Language/CLI.php | 15 + templates/cli/internal/cmd/init.go | 1 + templates/cli/internal/cmd/initpush.go | 1119 ++++++++++++++++ templates/cli/internal/cmd/initpush_test.go | 1235 ++++++++++++++++++ templates/cli/internal/cmd/initpushdetect.go | 319 +++++ 5 files changed, 2689 insertions(+) create mode 100644 templates/cli/internal/cmd/initpush.go create mode 100644 templates/cli/internal/cmd/initpush_test.go create mode 100644 templates/cli/internal/cmd/initpushdetect.go diff --git a/src/SDK/Language/CLI.php b/src/SDK/Language/CLI.php index b6251f8877..35ab5e1753 100644 --- a/src/SDK/Language/CLI.php +++ b/src/SDK/Language/CLI.php @@ -1126,6 +1126,21 @@ public function getFiles(): array 'destination' => 'internal/cmd/initskill.go', 'template' => 'cli/internal/cmd/initskill.go', ], + [ + 'scope' => 'default', + 'destination' => 'internal/cmd/initpush.go', + 'template' => 'cli/internal/cmd/initpush.go', + ], + [ + 'scope' => 'default', + 'destination' => 'internal/cmd/initpushdetect.go', + 'template' => 'cli/internal/cmd/initpushdetect.go', + ], + [ + 'scope' => 'default', + 'destination' => 'internal/cmd/initpush_test.go', + 'template' => 'cli/internal/cmd/initpush_test.go', + ], [ 'scope' => 'copy', 'destination' => 'internal/apns/apns.go', diff --git a/templates/cli/internal/cmd/init.go b/templates/cli/internal/cmd/init.go index ec6a874449..14075ad5a4 100644 --- a/templates/cli/internal/cmd/init.go +++ b/templates/cli/internal/cmd/init.go @@ -38,6 +38,7 @@ func newInitCommand() *cobra.Command { newInitFunctionCommand(), newInitSiteCommand(), newInitSkillCommand(), + newInitApnsCommand(), newInitBucketCommand(), newInitTeamCommand(), newInitTopicCommand(), diff --git a/templates/cli/internal/cmd/initpush.go b/templates/cli/internal/cmd/initpush.go new file mode 100644 index 0000000000..5a41d21049 --- /dev/null +++ b/templates/cli/internal/cmd/initpush.go @@ -0,0 +1,1119 @@ +//go:build !browser + +package cmd + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/x509" + "encoding/pem" + "errors" + "fmt" + "io" + "net/url" + "os" + "os/exec" + "path/filepath" + "regexp" + "strconv" + "strings" + "time" + + "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/apns" + "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/apns/appwrite" + "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/apns/expo" + "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/apns/fastlane" + "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/app" + "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/client" + "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/config" + "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/output" + "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/prompt" + "github.com/spf13/cobra" +) + +const ( + apnsKeyPage = "https://developer.apple.com/account/resources/authkeys/add" + apnsKeysListPage = "https://developer.apple.com/account/resources/authkeys/list" + appIDsPage = "https://developer.apple.com/account/resources/identifiers/list" + downloadTimeout = 15 * time.Minute + downloadPoll = time.Second +) + +var ( + errAutomaticKey = errors.New("automatic key creation chosen") + apnsKeyFileName = regexp.MustCompile(`^AuthKey_([A-Z0-9]{10})\.p8$`) + apnsKeyID = regexp.MustCompile(`^[A-Z0-9]{10}$`) +) + +type apnsOptions struct { + keyPath string + keyID string + teamID string + bundleID string + createKey bool + reset bool + environment string + setup string + // registerAppID registers the bundle ID as an App ID with Push + // Notifications, without asking. + registerAppID bool +} + +type pushSetup struct { + api *client.Client + prompter prompt.Prompter + out io.Writer + root string + keyDirs []string + downloads string + open func(string) + ctx context.Context + getenv func(string) string + // apnsAdapters are the ways --create-key can create an APNs key, and + // apnsSessions is where they keep a signed-in session. + apnsAdapters *apns.Registry + apnsSessions string + // appleID is the Apple ID a key was just created with, so the App ID + // check reuses that sign-in. + appleID string +} + +func newPushSetup(command *cobra.Command) (*pushSetup, error) { + context, err := newPushContext() + if err != nil { + return nil, err + } + + downloads := "" + if home, err := os.UserHomeDir(); err == nil { + downloads = filepath.Join(home, "Downloads") + } + + var keyDirs []string + for _, directory := range []string{context.local.Dirname(), "."} { + if absolute, err := filepath.Abs(directory); err == nil { + keyDirs = appendUnique(keyDirs, absolute) + } + } + + sessions := "" + if prefs, err := config.GlobalPath(app.ExecutableName); err == nil { + sessions = filepath.Join(filepath.Dir(prefs), "apns") + } + + return &pushSetup{ + api: context.api, + prompter: context.prompter, + out: command.OutOrStdout(), + root: ".", + keyDirs: keyDirs, + downloads: downloads, + open: openBrowser, + ctx: command.Context(), + getenv: os.Getenv, + apnsAdapters: apnsAdapters(), + apnsSessions: sessions, + }, nil +} + +func newInitApnsCommand() *cobra.Command { + options := apnsOptions{} + command := &cobra.Command{ + Use: "apns", + Short: "Set up Apple Push Notification service for Appwrite Push", + RunE: func(command *cobra.Command, args []string) error { + setup, err := newPushSetup(command) + if err != nil { + return err + } + detected, err := detectApple(setup.root) + if err != nil { + return err + } + + return setup.apns(options, detected) + }, + } + + flags := command.Flags() + flags.StringVar(&options.keyPath, "key-path", "", "Path to an APNs auth key (.p8)") + flags.StringVar(&options.keyID, "key-id", "", "APNs key ID") + flags.StringVar(&options.teamID, "team-id", "", "Apple Developer team ID") + flags.StringVar(&options.bundleID, "bundle-id", "", "iOS app bundle ID") + flags.BoolVar(&options.createKey, "create-key", false, "Create the APNs key by signing in with your Apple ID (experimental)") + flags.StringVar(&options.environment, "environment", string(apns.EnvironmentAll), "APNs environment to set up: production, sandbox or all") + flags.StringVar(&options.setup, "provider", "", "How --create-key creates the key: appwrite, expo or fastlane. Defaults to $"+apnsSetupVariable+", then "+defaultApnsSetup) + flags.BoolVar(&options.reset, "reset", false, "Sign in to Apple again instead of reusing the saved sign-in, and create the key (implies --create-key)") + flags.BoolVar(&options.registerAppID, "register-app-id", false, "Register the bundle ID as an App ID with Push Notifications in the Apple Developer portal, signing in with your Apple ID (experimental)") + command.MarkFlagsMutuallyExclusive("create-key", "key-path") + command.MarkFlagsMutuallyExclusive("reset", "key-path") + command.MarkFlagsMutuallyExclusive("reset", "key-id") + command.MarkFlagsMutuallyExclusive("create-key", "key-id") + + return command +} + +func (s *pushSetup) pick(value string, detected []string, question, flag string, validate func(string) error) (string, error) { + if value != "" { + if validate != nil { + if err := validate(value); err != nil { + return "", fmt.Errorf("%s: %w", flag, err) + } + } + + return value, nil + } + if len(detected) == 1 { + return detected[0], nil + } + if len(detected) > 1 { + return s.prompter.Choice(prompt.Choice{ + Message: question, + Options: prompt.Options(detected...), + Flag: flag, + }) + } + + return s.prompter.Text(prompt.Text{ + Message: question, + Flag: flag, + Validate: validate, + }) +} + +func requiredValue(name string) func(string) error { + return func(value string) error { + if strings.TrimSpace(value) == "" { + return fmt.Errorf("%s is required", name) + } + + return nil + } +} + +func matchesPattern(pattern *regexp.Regexp, description string) func(string) error { + return func(value string) error { + if !pattern.MatchString(value) { + return fmt.Errorf("%s", description) + } + + return nil + } +} + +func (s *pushSetup) apns(options apnsOptions, detected appleApp) error { + output.Log(s.out, "Setting up APNs ...") + // --reset is for signing in again, which only creating a key does. + if options.reset { + options.createKey = true + } + + environment, err := apnsEnvironment(options.environment) + if err != nil { + return err + } + // A setup chosen by name must exist before anything is written. + if options.setup != "" || s.env(apnsSetupVariable) != "" { + if _, err := s.apnsAdapter(options.setup); err != nil { + return err + } + } + wantProduction := environment != apns.EnvironmentSandbox + wantSandbox := environment != apns.EnvironmentProduction + + bundleID, err := s.pick(options.bundleID, detected.BundleIDs, + "Which iOS bundle ID should receive push notifications?", "--bundle-id", apns.CheckBundleID) + if err != nil { + return err + } + + existing, err := s.providers("apns") + if err != nil { + return err + } + production := findProvider(existing, func(provider messagingProvider) bool { + return provider.credential("bundleId") == bundleID && !provider.sandbox() + }) + sandbox := findProvider(existing, func(provider messagingProvider) bool { + return provider.credential("bundleId") == bundleID && provider.sandbox() + }) + setUp := func(provider *messagingProvider, wanted bool) bool { + return !wanted || (provider != nil && provider.Enabled) + } + if setUp(production, wantProduction) && setUp(sandbox, wantSandbox) && options.keyPath == "" && !options.reset && !app.Flags().Force { + output.Success(s.out, "APNs%s is already set up for %s.", environmentLabel(environment), bundleID) + output.Hint(s.out, "Pass --force or --key-path to replace the key.") + s.configureXcode(detected, bundleID) + + return s.prepareAppID(provisionedTeam(production, sandbox, detected.teams(bundleID)), bundleID, options, false) + } + + teams := detected.teams(bundleID) + for _, provider := range []*messagingProvider{production, sandbox} { + if provider != nil && len(teams) == 0 && appleTeamID.MatchString(provider.credential("teamId")) { + teams = []string{provider.credential("teamId")} + } + } + pickTeam := func() (string, error) { + return s.pick(options.teamID, teams, + "What is your Apple Developer team ID?", "--team-id", + matchesPattern(appleTeamID, "a team ID is 10 uppercase letters or digits")) + } + // Without a known team, signing in with the Apple ID chooses it, so the + // question waits until the key has come from somewhere else. + teamID := "" + if options.teamID != "" || len(teams) > 0 || options.keyPath != "" { + if teamID, err = pickTeam(); err != nil { + return err + } + } + + keyPath := options.keyPath + createdKeyID := "" + if keyPath == "" { + var keyTeam string + keyPath, createdKeyID, keyTeam, err = s.apnsKey(teamID, options, environment) + if err != nil { + return err + } + if teamID == "" { + teamID = keyTeam + } + } + if teamID == "" { + if teamID, err = pickTeam(); err != nil { + return err + } + } + + key, err := readApnsKey(keyPath) + if err != nil { + return err + } + if options.keyPath != "" { + s.checkKeyLocation(expandHome(keyPath)) + } + + keyID := options.keyID + // A created key's ID comes from Apple; any other would not match the .p8. + if createdKeyID != "" { + if keyID != "" && keyID != createdKeyID { + output.Warn(s.out, "Using the created key's ID %s instead of --key-id %s.", createdKeyID, keyID) + } + keyID = createdKeyID + } + if keyID == "" { + if match := apnsKeyFileName.FindStringSubmatch(filepath.Base(keyPath)); match != nil { + keyID = match[1] + } + } + keyID, err = s.pick(keyID, nil, "What is the key ID of this APNs key?", "--key-id", + matchesPattern(apnsKeyID, "a key ID is 10 uppercase letters or digits")) + if err != nil { + return err + } + + for _, target := range []struct { + sandbox bool + wanted bool + name string + existing *messagingProvider + }{ + {false, wantProduction, "APNs (" + bundleID + ")", production}, + {true, wantSandbox, "APNs sandbox (" + bundleID + ")", sandbox}, + } { + if !target.wanted { + continue + } + body := map[string]any{ + "name": target.name, + "authKey": key, + "authKeyId": keyID, + "teamId": teamID, + "bundleId": bundleID, + "sandbox": target.sandbox, + "enabled": true, + } + if err := s.upsertProvider("apns", target.existing, body); err != nil { + return err + } + } + + s.configureXcode(detected, bundleID) + output.Success(s.out, "APNs%s is set up for %s.", environmentLabel(environment), bundleID) + + // A key created a moment ago left a fresh sign-in to check the App ID with. + return s.prepareAppID(teamID, bundleID, options, createdKeyID != "") +} + +// apnsKey finds or creates the APNs key and returns its path. With +// --create-key, signing in with the Apple ID is the only way, and a failure +// stops the command. Otherwise it is offered alongside the browser flow, and +// when it fails the other ways of providing the key remain. +func (s *pushSetup) apnsKey(teamID string, options apnsOptions, environment apns.Environment) (string, string, string, error) { + create := options.createKey + explicit := create + automatic := "" + if s.apnsAdapters != nil && len(s.apnsAdapters.Names()) > 0 { + automatic = "Sign in with your Apple ID and create one (experimental)" + } + for { + if !create { + path, _, err := s.acquireKey("APNs auth key (.p8)", automatic, "Create one in the Apple Developer portal", "--key-path", apnsKeyPage, + "In the Apple Developer portal, create a key with Apple Push Notifications service (APNs) enabled and download it.", + func(path string) bool { + _, err := readApnsKey(path) + + return strings.HasSuffix(path, ".p8") && err == nil + }) + if !errors.Is(err, errAutomaticKey) { + return path, "", "", err + } + } + create = false + + path, keyID, keyTeam, err := s.createApnsKey(teamID, options, environment) + var maxKeys *apns.MaxKeysError + if errors.As(err, &maxKeys) { + output.Warn(s.out, "Apple allows two APNs keys per team, and this team has no free slot. Keys on the team:") + for _, key := range maxKeys.Keys { + output.Log(s.out, " %s (%s)", key.Name, key.ID) + } + output.Hint(s.out, "One key works for every app on the team. Reuse a .p8 you saved earlier, or revoke a key you no longer use at %s.", apnsKeysListPage) + + return "", "", "", errors.New("no APNs key can be created on this team. Pass --key-path with an existing .p8 instead") + } + if err == nil { + return path, keyID, keyTeam, nil + } + // --create-key asked for a new key: falling back to a .p8 already in + // the project folder would hide that none was created. + if explicit { + return "", "", "", fmt.Errorf("could not create the APNs key with your Apple ID: %w", err) + } + output.Warn(s.out, "Could not create the APNs key with your Apple ID: %s.", err) + output.Log(s.out, "Provide the key another way instead.") + automatic = "" + } +} + +const ( + // apnsSetupVariable picks the APNs key setup when --provider is not + // passed, so a broken one can be swapped without a release. + apnsSetupVariable = "APPWRITE_APNS_SETUP" + defaultApnsSetup = "appwrite" + // The Apple ID and password, when set, answer the sign-in questions. + appleIDVariable = "APPWRITE_APPLE_ID" + applePasswordVariable = "APPWRITE_APPLE_PASSWORD" +) + +// apnsAdapters lists the APNs key setups this build offers. Each lives in its +// own package under internal/apns and is added here. +func apnsAdapters() *apns.Registry { + return apns.NewRegistry(appwrite.New(), expo.New(), fastlane.New()) +} + +// apnsAdapter picks the setup: --provider, then APPWRITE_APNS_SETUP, then +// appwrite. +func (s *pushSetup) apnsAdapter(setup string) (apns.Adapter, error) { + if setup == "" { + setup = s.env(apnsSetupVariable) + } + if setup == "" { + setup = defaultApnsSetup + } + if s.apnsAdapters == nil { + return nil, fmt.Errorf("APNs key setup %q is not available: this build has none", setup) + } + + return s.apnsAdapters.Get(setup) +} + +func (s *pushSetup) env(name string) string { + if s.getenv == nil { + return "" + } + + return s.getenv(name) +} + +func (s *pushSetup) context() context.Context { + if s.ctx == nil { + return context.Background() + } + + return s.ctx +} + +// apnsEnvironment reads --environment. Empty means both. +func apnsEnvironment(value string) (apns.Environment, error) { + environment, err := apns.ParseEnvironment(value) + if err != nil { + return "", fmt.Errorf("--environment must be production, sandbox or all, not %q", value) + } + + return environment, nil +} + +// environmentLabel names a single environment in messages, and nothing for +// both. +func environmentLabel(environment apns.Environment) string { + if environment == apns.EnvironmentAll { + return "" + } + + return " (" + string(environment) + ")" +} + +// apnsAsker asks an APNs setup's questions through the CLI's prompts, so they +// look like every other question and fail with a clear message without a +// terminal. +type apnsAsker struct { + prompter prompt.Prompter +} + +func (a apnsAsker) Ask(question string, secret bool) (string, error) { + return a.prompter.Text(prompt.Text{Message: question, Secret: secret, Validate: requiredValue("an answer")}) +} + +func (a apnsAsker) Choose(question string, options []string) (int, error) { + choices := make([]prompt.Option, len(options)) + for index, label := range options { + choices[index] = prompt.Option{Label: label, Value: strconv.Itoa(index)} + } + value, err := a.prompter.Choice(prompt.Choice{Message: question, Options: choices}) + if err != nil { + return 0, err + } + + return strconv.Atoi(value) +} + +// appleRequest is how every Apple Developer portal step signs in: the Apple ID +// and password from the environment when set, the saved session unless reset. +func (s *pushSetup) appleRequest(adapter apns.Adapter, teamID string, reset bool) apns.Request { + request := apns.Request{ + TeamID: teamID, + AppleID: s.env(appleIDVariable), + Password: s.env(applePasswordVariable), + Reset: reset, + Asker: apnsAsker{s.prompter}, + Log: func(format string, args ...any) { + output.Log(s.out, format, args...) + }, + } + if s.apnsSessions != "" { + request.SessionDir = filepath.Join(s.apnsSessions, adapter.Name()) + } + + return request +} + +// logAppleSignIn says how the coming sign-in to Apple will happen. +func (s *pushSetup) logAppleSignIn(adapter apns.Adapter, request apns.Request) { + output.Log(s.out, "Signing in to the Apple Developer portal (%s).", adapter.Name()) + if request.Reset { + output.Log(s.out, "Signing in again: the saved Apple sign-in is not used (--reset).") + } + if request.AppleID != "" { + output.Log(s.out, "Using the Apple ID from %s.", appleIDVariable) + } + if request.Password != "" { + output.Log(s.out, "Using the Apple ID password from %s.", applePasswordVariable) + } +} + +// prepareAppID makes sure the bundle ID is an App ID with Push Notifications +// on the team: APNs refuses pushes for any other bundle ID (TopicDisallowed), +// and Xcode registers it only for a device build with the capability added. +// +// Every init apns runs it. It uses the sign-in a new key just left, or a +// saved one, and asks before each change; without either it asks whether to +// sign in, and skips when there is no terminal. --register-app-id signs in +// when needed, makes the changes without asking and fails the command when +// it cannot. +func (s *pushSetup) prepareAppID(teamID, bundleID string, options apnsOptions, signedIn bool) error { + explicit := options.registerAppID + fail := func(format string, args ...any) error { + message := fmt.Sprintf(format, args...) + if explicit { + return errors.New(message) + } + output.Warn(s.out, "%s.", message) + output.Hint(s.out, "APNs accepts pushes only for a bundle ID registered as an App ID with Push Notifications. Check it at %s.", appIDsPage) + + return nil + } + adapter, err := s.apnsAdapter(options.setup) + if err != nil { + return fail("Could not check the App ID for %s: %s", bundleID, err) + } + + output.Log(s.out, "Checking the App ID for %s ...", bundleID) + // A key created a moment ago left a fresh, saved sign-in: the check reuses + // it for the same Apple ID, and --reset is not repeated. + request := s.appleRequest(adapter, teamID, options.reset && !options.createKey) + if signedIn && request.AppleID == "" { + request.AppleID = s.appleID + } + request.SavedSignInOnly = signedIn || !explicit + if !request.SavedSignInOnly { + s.logAppleSignIn(adapter, request) + } + appID := apns.AppIDRequest{BundleID: bundleID, Ask: !explicit} + result, err := apns.PrepareAppID(s.context(), adapter, request, appID) + // Just signed in, or asked for explicitly: sign in again without asking. + if errors.Is(err, apns.ErrSignInNeeded) && (signedIn || explicit) { + request.SavedSignInOnly = false + s.logAppleSignIn(adapter, request) + result, err = apns.PrepareAppID(s.context(), adapter, request, appID) + } + if errors.Is(err, apns.ErrSignInNeeded) { + answer, askErr := s.prompter.Choice(prompt.Choice{ + Message: "Sign in with your Apple ID to check the App ID for " + bundleID + "?", + Description: "APNs refuses pushes for a bundle ID that is not registered as an App ID with Push Notifications.", + Options: []prompt.Option{ + {Label: "Sign in and check", Value: "check"}, + {Label: "Skip", Value: "skip"}, + }, + }) + if askErr != nil || answer != "check" { + reason := "" + if askErr != nil { + reason = ": there is no saved Apple sign-in, and no terminal to sign in" + } + output.Hint(s.out, "Skipped the App ID check%s. Run `appwrite init apns --register-app-id` in a terminal, or check %s at %s.", reason, bundleID, appIDsPage) + + return nil + } + request.SavedSignInOnly = false + s.logAppleSignIn(adapter, request) + result, err = apns.PrepareAppID(s.context(), adapter, request, appID) + } + var unanswered *prompt.NonInteractiveError + if errors.As(err, &unanswered) { + return fail("Could not check the App ID for %s: %s", bundleID, needsTerminal(unanswered)) + } + if err != nil { + return fail("Could not check the App ID for %s: %s", bundleID, err) + } + + switch { + case result.Created: + output.Success(s.out, "Registered %s as an App ID with Push Notifications on team %s.", bundleID, result.TeamID) + case result.PushTurnedOn: + output.Success(s.out, "Turned on Push Notifications for the App ID %s.", bundleID) + case result.Registered && result.Push: + output.Success(s.out, "%s is registered as an App ID with Push Notifications.", bundleID) + case !result.Registered: + output.Warn(s.out, "%s is not registered as an App ID on team %s: APNs refuses pushes for it until it is.", bundleID, result.TeamID) + output.Hint(s.out, "Run `appwrite init apns --register-app-id`, or register it at %s.", appIDsPage) + default: + output.Warn(s.out, "Push Notifications is off for the App ID %s: APNs refuses pushes for it until it is on.", bundleID) + output.Hint(s.out, "Run `appwrite init apns --register-app-id`, or turn it on at %s.", appIDsPage) + } + + return nil +} + +// needsTerminal says that signing in to Apple asked a question without a terminal. +func needsTerminal(unanswered *prompt.NonInteractiveError) string { + return fmt.Sprintf("signing in to Apple asked %q, which needs an interactive terminal. %s and %s answer the Apple ID and password, but the two-factor code always needs one", unanswered.Message, appleIDVariable, applePasswordVariable) +} + +// provisionedTeam is the team of an existing APNs provider for the bundle ID, +// else the one detected in the project, else none (signing in picks it). +func provisionedTeam(production, sandbox *messagingProvider, detected []string) string { + for _, provider := range []*messagingProvider{production, sandbox} { + if provider != nil && appleTeamID.MatchString(provider.credential("teamId")) { + return provider.credential("teamId") + } + } + if len(detected) == 1 { + return detected[0] + } + + return "" +} + +// createApnsKey creates a key through the Apple Developer portal and saves it +// into the project folder before anything else happens, because Apple lets a +// key be downloaded only once. +func (s *pushSetup) createApnsKey(teamID string, options apnsOptions, environment apns.Environment) (string, string, string, error) { + adapter, err := s.apnsAdapter(options.setup) + if err != nil { + return "", "", "", err + } + request := s.appleRequest(adapter, teamID, options.reset) + s.logAppleSignIn(adapter, request) + request.Name = "Appwrite Push " + time.Now().Format("20060102150405") + request.Environment = environment + key, err := apns.CreateKey(s.context(), adapter, request) + if err == nil { + s.appleID = key.AppleID + } + var unanswered *prompt.NonInteractiveError + if errors.As(err, &unanswered) { + return "", "", "", fmt.Errorf("%s. Without a terminal, pass --key-path with an existing .p8 instead of --create-key", needsTerminal(unanswered)) + } + if err != nil { + return "", "", "", err + } + + directory := s.root + if len(s.keyDirs) > 0 { + directory = s.keyDirs[0] + } + path := filepath.Join(directory, "AuthKey_"+key.KeyID+".p8") + file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if err != nil { + return "", "", "", fmt.Errorf("could not save the new key %s: %w", key.KeyID, err) + } + _, err = file.WriteString(key.P8) + if closeErr := file.Close(); err == nil { + err = closeErr + } + if err != nil { + return "", "", "", fmt.Errorf("could not save the new key %s: %w", key.KeyID, err) + } + + output.Success(s.out, "Created APNs key %s on team %s and saved it to %s.", key.KeyID, key.TeamID, s.display(path)) + output.Hint(s.out, "Apple lets you download this key only once. Keep the file somewhere safe and out of version control.") + s.checkKeyLocation(path) + + return path, key.KeyID, key.TeamID, nil +} + +func (s *pushSetup) acquireKey(what, automatic, browserLabel, flag, page, instructions string, accept func(string) bool) (string, bool, error) { + found := s.projectKeys(accept) + if len(found) == 1 { + output.Log(s.out, "Using %s from the project folder.", s.display(found[0])) + s.checkKeyLocation(found[0]) + + return found[0], true, nil + } + if len(found) > 1 { + options := make([]prompt.Option, 0, len(found)) + for _, path := range found { + options = append(options, prompt.Option{Label: s.display(path), Value: path}) + } + path, err := s.prompter.Choice(prompt.Choice{ + Message: fmt.Sprintf("Which %s should be used?", what), + Options: options, + Flag: flag, + }) + if err != nil { + return "", false, err + } + s.checkKeyLocation(path) + + return path, true, nil + } + + methods := []prompt.Option{ + {Label: browserLabel, Value: "browser"}, + {Label: "Use a file I already have", Value: "file"}, + } + if automatic != "" { + first := prompt.Option{Label: automatic, Value: "automatic"} + methods = append([]prompt.Option{first}, methods...) + } + method, err := s.prompter.Choice(prompt.Choice{ + Message: fmt.Sprintf("How would you like to provide the %s?", what), + Options: methods, + Default: methods[0].Value, + Flag: flag, + }) + if err != nil { + return "", false, err + } + if method == "automatic" { + return "", false, errAutomaticKey + } + + if method == "file" { + path, err := s.prompter.Text(prompt.Text{ + Message: fmt.Sprintf("Path to the %s", what), + Flag: flag, + Validate: func(path string) error { + if _, err := os.Stat(expandHome(path)); err != nil { + return fmt.Errorf("no file at %s", path) + } + + return nil + }, + }) + if err != nil { + return "", false, err + } + s.checkKeyLocation(expandHome(path)) + + return path, false, nil + } + + directories := append([]string{}, s.keyDirs...) + if s.downloads != "" { + if info, err := os.Stat(s.downloads); err == nil && info.IsDir() { + directories = appendUnique(directories, s.downloads) + } + } + if len(directories) == 0 { + return "", false, fmt.Errorf("no folder to watch for the key. Pass %s instead", flag) + } + + output.Log(s.out, "%s", instructions) + output.Log(s.out, "Opening %s", page) + s.open(page) + if len(s.keyDirs) > 0 { + output.Log(s.out, "Save the file into %s, or let your browser download it to %s. Waiting (Ctrl-C to cancel) ...", + s.keyDirs[0], s.downloads) + } else { + output.Log(s.out, "Waiting for the file in %s (Ctrl-C to cancel) ...", s.downloads) + } + + path, err := waitForKeyFile(directories, time.Now(), accept, downloadTimeout, downloadPoll) + if err != nil { + return "", false, fmt.Errorf("%w. Pass %s instead", err, flag) + } + output.Log(s.out, "Found %s", path) + s.checkKeyLocation(path) + + return path, false, nil +} + +func (s *pushSetup) projectKeys(accept func(string) bool) []string { + var found []string + for _, directory := range s.keyDirs { + entries, err := os.ReadDir(directory) + if err != nil { + continue + } + for _, entry := range entries { + path := filepath.Join(directory, entry.Name()) + if !entry.IsDir() && accept(path) { + found = appendUnique(found, path) + } + } + } + + return found +} + +func (s *pushSetup) display(path string) string { + for _, directory := range s.keyDirs { + if relative, err := filepath.Rel(directory, path); err == nil && !strings.HasPrefix(relative, "..") { + return relative + } + } + + return path +} + +func (s *pushSetup) checkKeyLocation(path string) { + absolute, err := filepath.Abs(path) + if err != nil { + return + } + git, err := exec.LookPath("git") + if err != nil { + return + } + check := exec.Command(git, "-C", filepath.Dir(absolute), "check-ignore", "-q", filepath.Base(absolute)) + if err := check.Run(); err != nil { + var exit *exec.ExitError + if errors.As(err, &exit) && exit.ExitCode() == 1 { + output.Warn(s.out, "%s is inside a git repository and not ignored. Add it to .gitignore so the key is never committed.", s.display(absolute)) + } + } +} + +func waitForKeyFile(directories []string, since time.Time, accept func(string) bool, timeout, poll time.Duration) (string, error) { + seen := map[string]time.Time{} + for _, directory := range directories { + entries, err := os.ReadDir(directory) + if err != nil { + continue + } + for _, entry := range entries { + if info, err := entry.Info(); err == nil && info.ModTime().Before(since) { + seen[filepath.Join(directory, entry.Name())] = info.ModTime() + } + } + } + + deadline := time.Now().Add(timeout) + for { + for _, directory := range directories { + entries, err := os.ReadDir(directory) + if err != nil { + continue + } + for _, entry := range entries { + if entry.IsDir() { + continue + } + info, err := entry.Info() + if err != nil { + continue + } + path := filepath.Join(directory, entry.Name()) + if previous, ok := seen[path]; ok && !info.ModTime().After(previous) { + continue + } + if accept(path) { + return path, nil + } + } + } + if time.Now().After(deadline) { + return "", fmt.Errorf("no matching file appeared in %s within %s", strings.Join(directories, " or "), timeout) + } + time.Sleep(poll) + } +} + +func expandHome(path string) string { + if strings.HasPrefix(path, "~/") { + if home, err := os.UserHomeDir(); err == nil { + return filepath.Join(home, path[2:]) + } + } + + return path +} + +func readApnsKey(path string) (string, error) { + contents, err := os.ReadFile(expandHome(path)) + if err != nil { + return "", err + } + block, _ := pem.Decode(contents) + if block == nil || block.Type != "PRIVATE KEY" { + return "", fmt.Errorf("%s is not an APNs auth key: expected a PEM 'PRIVATE KEY' block", path) + } + key, err := x509.ParsePKCS8PrivateKey(block.Bytes) + if err != nil { + return "", fmt.Errorf("%s is not an APNs auth key: %w", path, err) + } + ecKey, ok := key.(*ecdsa.PrivateKey) + if !ok || ecKey.Curve != elliptic.P256() { + return "", fmt.Errorf("%s is not an APNs auth key: expected a P-256 elliptic curve key", path) + } + + return string(contents), nil +} + +type messagingProvider struct { + ID string `json:"$id"` + Name string `json:"name"` + Provider string `json:"provider"` + Enabled bool `json:"enabled"` + Credentials map[string]any `json:"credentials"` + Options map[string]any `json:"options"` +} + +func (p messagingProvider) credential(name string) string { + value, _ := p.Credentials[name].(string) + + return value +} + +func (p messagingProvider) sandbox() bool { + value, _ := p.Options["sandbox"].(bool) + + return value +} + +func findProvider(providers []messagingProvider, match func(messagingProvider) bool) *messagingProvider { + for index := range providers { + if match(providers[index]) { + return &providers[index] + } + } + + return nil +} + +func (s *pushSetup) providers(kind string) ([]messagingProvider, error) { + query := url.Values{"queries[]": []string{ + `{"method":"equal","attribute":"provider","values":["` + kind + `"]}`, + `{"method":"limit","values":[100]}`, + }} + var list struct { + Providers []messagingProvider `json:"providers"` + } + if err := s.api.Call("GET", "/messaging/providers?"+query.Encode(), nil, &list); err != nil { + return nil, fmt.Errorf("could not list messaging providers: %w", err) + } + + return list.Providers, nil +} + +func (s *pushSetup) upsertProvider(kind string, existing *messagingProvider, body map[string]any) error { + var saved messagingProvider + if existing == nil { + body["providerId"] = "unique()" + if err := s.api.Call("POST", "/messaging/providers/"+kind, body, &saved); err != nil { + return fmt.Errorf("could not create %s: %w", body["name"], err) + } + output.Success(s.out, "Created provider %s ( %s )", saved.Name, saved.ID) + + return nil + } + + if err := s.api.Call("PATCH", "/messaging/providers/"+kind+"/"+url.PathEscape(existing.ID), body, &saved); err != nil { + return fmt.Errorf("could not update %s: %w", existing.Name, err) + } + output.Success(s.out, "Updated provider %s ( %s )", saved.Name, saved.ID) + + return nil +} + +func (s *pushSetup) configureXcode(detected appleApp, bundleID string) { + targets := detected.targets(bundleID) + inXcode, entitlements, infoPlists := false, 0, 0 + for _, target := range targets { + if target.Project == "" { + continue + } + inXcode = true + for _, path := range target.Entitlements { + entitlements++ + changed, err := editPlist(path, func(contents string) string { + return ensurePlistString(contents, "aps-environment", "development") + }) + s.reportEdit(path, "aps-environment", changed, err) + } + for _, path := range target.InfoPlists { + infoPlists++ + changed, err := editPlist(path, func(contents string) string { + return ensurePlistArrayValue(contents, "UIBackgroundModes", "remote-notification") + }) + s.reportEdit(path, "the remote-notification background mode", changed, err) + } + } + + if inXcode && entitlements == 0 { + output.Hint(s.out, "In Xcode, open Signing & Capabilities and add Push Notifications, so the app gets an aps-environment entitlement.") + } + if inXcode && infoPlists == 0 { + output.Hint(s.out, "In Xcode, add Background Modes and tick Remote notifications.") + } + if detected.ExpoConfig != "" { + output.Hint(s.out, "Expo regenerates ios/ on prebuild. In %s, set expo.ios.entitlements[\"aps-environment\"] to \"development\" and add \"remote-notification\" to expo.ios.infoPlist.UIBackgroundModes.", + filepath.Base(detected.ExpoConfig)) + } +} + +func (s *pushSetup) reportEdit(path, what string, changed bool, err error) { + switch { + case err != nil: + output.Warn(s.out, "Could not add %s to %s: %s", what, path, err) + case changed: + output.Success(s.out, "Added %s to %s", what, path) + } +} + +func editPlist(path string, edit func(string) string) (bool, error) { + contents, err := os.ReadFile(path) + if err != nil { + return false, err + } + original := string(contents) + if !strings.Contains(original, ""); index >= 0 && !strings.Contains(contents, "") { + indent, _ := lineIndent(contents, index) + contents = contents[:index] + "\n" + indent + "" + contents[index+len(""):] + } + end := strings.LastIndex(contents, "") + if end < 0 { + return contents, -1, "", "" + } + closing, lineStart := lineIndent(contents, end) + + unit := "\t" + if key := plistKeyLine.FindStringSubmatch(contents); key != nil && strings.HasPrefix(key[1], closing) && len(key[1]) > len(closing) { + unit = key[1][len(closing):] + } + + return contents, lineStart, closing + unit, unit +} + +var plistKeyLine = regexp.MustCompile(`(?m)^([ \t]*)`) + +func ensurePlistString(contents, key, value string) string { + if strings.Contains(contents, ""+key+"") { + return contents + } + contents, at, indent, _ := topLevelDict(contents) + if at < 0 { + return contents + } + + return contents[:at] + indent + "" + key + "\n" + indent + "" + value + "\n" + contents[at:] +} + +func ensurePlistArrayValue(contents, key, value string) string { + marker := "" + key + "" + entry := "" + value + "" + if index := strings.Index(contents, marker); index >= 0 { + after := index + len(marker) + rest := contents[after:] + if empty := strings.Index(rest, ""); empty >= 0 && strings.TrimSpace(rest[:empty]) == "" { + keyIndent, _ := lineIndent(contents, index) + _, _, _, unit := topLevelDict(contents) + at := after + empty + + return contents[:at] + "\n" + keyIndent + unit + entry + "\n" + keyIndent + "" + contents[at+len(""):] + } + open := strings.Index(rest, "") + closing := strings.Index(rest, "") + if open < 0 || closing < open || strings.TrimSpace(rest[:open]) != "" { + return contents + } + if strings.Contains(rest[open:closing], entry) { + return contents + } + keyIndent, _ := lineIndent(contents, index) + _, _, _, unit := topLevelDict(contents) + _, lineStart := lineIndent(contents, after+closing) + if lineStart > 0 && contents[lineStart-1] != '\n' { + return contents + } + + return contents[:lineStart] + keyIndent + unit + entry + "\n" + contents[lineStart:] + } + + contents, at, indent, unit := topLevelDict(contents) + if at < 0 { + return contents + } + + return contents[:at] + indent + marker + "\n" + indent + "\n" + indent + unit + entry + "\n" + indent + "\n" + contents[at:] +} diff --git a/templates/cli/internal/cmd/initpush_test.go b/templates/cli/internal/cmd/initpush_test.go new file mode 100644 index 0000000000..8aa20b3f77 --- /dev/null +++ b/templates/cli/internal/cmd/initpush_test.go @@ -0,0 +1,1235 @@ +package cmd + +import ( + "bytes" + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/json" + "encoding/pem" + "errors" + "io" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/apns" + "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/apns/apnstest" + "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/client" + "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/prompt" +) + +const testPbxproj = `// !$*UTF8*$! +{ + objects = { + +/* Begin PBXNativeTarget section */ + A00000000000000000000001 /* MyApp */ = { + isa = PBXNativeTarget; + buildConfigurationList = B00000000000000000000001 /* Build configuration list for PBXNativeTarget "MyApp" */; + name = MyApp; + productType = "com.apple.product-type.application"; + }; + A00000000000000000000002 /* MyAppTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = B00000000000000000000002 /* Build configuration list for PBXNativeTarget "MyAppTests" */; + name = MyAppTests; + productType = "com.apple.product-type.bundle.unit-test"; + }; + A00000000000000000000003 /* Widget */ = { + isa = PBXNativeTarget; + buildConfigurationList = B00000000000000000000003 /* Build configuration list for PBXNativeTarget "Widget" */; + name = Widget; + productType = "com.apple.product-type.app-extension"; + }; + A00000000000000000000004 /* Admin */ = { + isa = PBXNativeTarget; + buildConfigurationList = B00000000000000000000004 /* Build configuration list for PBXNativeTarget "Admin" */; + name = Admin; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin XCBuildConfiguration section */ + C00000000000000000000001 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = MyApp/MyApp.entitlements; + DEVELOPMENT_TEAM = ABCDE12345; + INFOPLIST_FILE = MyApp/Info.plist; + PRODUCT_BUNDLE_IDENTIFIER = "org.reactjs.native.example.$(PRODUCT_NAME:rfc1034identifier)"; + PRODUCT_NAME = "My App"; + }; + name = Debug; + }; + C00000000000000000000002 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + DEVELOPMENT_TEAM = ABCDE12345; + PRODUCT_BUNDLE_IDENTIFIER = com.example.MyAppTests; + }; + name = Debug; + }; + C00000000000000000000003 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = Widget/Widget.entitlements; + DEVELOPMENT_TEAM = ABCDE12345; + INFOPLIST_FILE = Widget/Info.plist; + PRODUCT_BUNDLE_IDENTIFIER = com.example.widget; + }; + name = Debug; + }; + C00000000000000000000004 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = Admin/Admin.entitlements; + DEVELOPMENT_TEAM = FGHIJ67890; + INFOPLIST_FILE = Admin/Info.plist; + PRODUCT_BUNDLE_IDENTIFIER = com.example.admin; + }; + name = Debug; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + B00000000000000000000001 /* Build configuration list for PBXNativeTarget "MyApp" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + C00000000000000000000001 /* Debug */, + ); + }; + B00000000000000000000002 /* Build configuration list for PBXNativeTarget "MyAppTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + C00000000000000000000002 /* Debug */, + ); + }; + B00000000000000000000003 /* Build configuration list for PBXNativeTarget "Widget" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + C00000000000000000000003 /* Debug */, + ); + }; + B00000000000000000000004 /* Build configuration list for PBXNativeTarget "Admin" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + C00000000000000000000004 /* Debug */, + ); + }; +/* End XCConfigurationList section */ + }; +} +` + +const myAppBundle = "org.reactjs.native.example.My-App" + +func writeFile(t *testing.T, path, contents string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(contents), 0o644); err != nil { + t.Fatal(err) + } +} + +func TestDetectAppleReadsTheAppTarget(t *testing.T) { + root := t.TempDir() + writeFile(t, filepath.Join(root, "ios", "MyApp.xcodeproj", "project.pbxproj"), testPbxproj) + writeFile(t, filepath.Join(root, "node_modules", "dep", "Dep.xcodeproj", "project.pbxproj"), + strings.ReplaceAll(testPbxproj, "ABCDE12345", "ZZZZZ99999")) + + app, err := detectApple(root) + if err != nil { + t.Fatal(err) + } + + if strings.Join(app.BundleIDs, ",") != "com.example.admin,"+myAppBundle { + t.Errorf("bundle IDs = %v", app.BundleIDs) + } + if strings.Join(app.teams(myAppBundle), ",") != "ABCDE12345" || strings.Join(app.teams("com.example.admin"), ",") != "FGHIJ67890" { + t.Errorf("teams = %v / %v", app.teams(myAppBundle), app.teams("com.example.admin")) + } + targets := app.targets(myAppBundle) + if len(targets) != 1 { + t.Fatalf("targets = %+v", app.Targets) + } + if strings.Join(targets[0].Entitlements, ",") != filepath.Join(root, "ios", "MyApp", "MyApp.entitlements") { + t.Errorf("entitlements = %v", targets[0].Entitlements) + } + if strings.Join(targets[0].InfoPlists, ",") != filepath.Join(root, "ios", "MyApp", "Info.plist") { + t.Errorf("Info.plist = %v", targets[0].InfoPlists) + } +} + +func TestDetectReadsExpoConfig(t *testing.T) { + root := t.TempDir() + writeFile(t, filepath.Join(root, "app.json"), `{"expo": { + "ios": {"bundleIdentifier": "com.example.expo", "appleTeamId": "ABCDE12345"} +}}`) + + apple, _ := detectApple(root) + + if apple.ExpoConfig == "" || len(apple.BundleIDs) != 1 || apple.BundleIDs[0] != "com.example.expo" || apple.TeamIDs[0] != "ABCDE12345" { + t.Errorf("apple = %+v", apple) + } +} + +func TestEnsurePlistEdits(t *testing.T) { + empty := "\n\n\n\n" + got := ensurePlistString(empty, "aps-environment", "development") + want := "\n\n\n\taps-environment\n\tdevelopment\n\n\n" + if got != want { + t.Errorf("empty dict:\n%s", got) + } + if ensurePlistString(got, "aps-environment", "production") != got { + t.Error("an existing aps-environment was changed") + } + + info := "\n\n\tCFBundleName\n\tApp\n\tUIBackgroundModes\n\t\n\t\tfetch\n\t\n\n\n" + got = ensurePlistArrayValue(info, "UIBackgroundModes", "remote-notification") + if !strings.Contains(got, "\t\n\t\tfetch\n\t\tremote-notification\n\t") { + t.Errorf("existing array:\n%s", got) + } + if ensurePlistArrayValue(got, "UIBackgroundModes", "remote-notification") != got { + t.Error("remote-notification was added twice") + } + + expo := "\n \n CFBundleName\n App\n \n\n" + got = ensurePlistArrayValue(expo, "UIBackgroundModes", "remote-notification") + if !strings.Contains(got, " App\n UIBackgroundModes\n \n remote-notification\n \n \n") { + t.Errorf("two-space plist:\n%s", got) + } + got = ensurePlistArrayValue(strings.Replace(got, "remote-notification", "fetch", 1), "UIBackgroundModes", "remote-notification") + if !strings.Contains(got, " fetch\n remote-notification\n ") { + t.Errorf("two-space array:\n%s", got) + } + + selfClosing := "\n\n\tUIBackgroundModes\n\t\n\n\n" + got = ensurePlistArrayValue(selfClosing, "UIBackgroundModes", "remote-notification") + if got != "\n\n\tUIBackgroundModes\n\t\n\t\tremote-notification\n\t\n\n\n" { + t.Errorf("self-closing array:\n%s", got) + } + + bare := "\n\n\tNested\n\t\n\t\n\n\n" + got = ensurePlistArrayValue(bare, "UIBackgroundModes", "remote-notification") + if !strings.HasSuffix(got, "\t\n\tUIBackgroundModes\n\t\n\t\tremote-notification\n\t\n\n\n") { + t.Errorf("new array:\n%s", got) + } +} + +func writeKey(t *testing.T, path string, key any) { + t.Helper() + encoded, err := x509.MarshalPKCS8PrivateKey(key) + if err != nil { + t.Fatal(err) + } + writeFile(t, path, string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: encoded}))) +} + +func testApnsKey(t *testing.T, directory string) string { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + path := filepath.Join(directory, "AuthKey_KEY1234567.p8") + writeKey(t, path, key) + + return path +} + +func TestReadApnsKeyRejectsOtherKeys(t *testing.T) { + directory := t.TempDir() + if _, err := readApnsKey(testApnsKey(t, directory)); err != nil { + t.Errorf("P-256 key rejected: %s", err) + } + + rsaKey, _ := rsa.GenerateKey(rand.Reader, 2048) + rsaPath := filepath.Join(directory, "rsa.p8") + writeKey(t, rsaPath, rsaKey) + if _, err := readApnsKey(rsaPath); err == nil || !strings.Contains(err.Error(), "P-256") { + t.Errorf("RSA key accepted: %v", err) + } +} + +func TestWaitForKeyFileIgnoresEarlierFiles(t *testing.T) { + directory := t.TempDir() + old := testApnsKey(t, directory) + past := time.Now().Add(-time.Hour) + if err := os.Chtimes(old, past, past); err != nil { + t.Fatal(err) + } + + started := time.Now() + fresh := filepath.Join(directory, "AuthKey_NEW1234567.p8") + go func() { + time.Sleep(50 * time.Millisecond) + writeFile(t, filepath.Join(directory, "notes.txt"), "not a key") + data, _ := os.ReadFile(old) + writeFile(t, fresh, string(data)) + }() + + path, err := waitForKeyFile([]string{t.TempDir(), directory}, started, func(path string) bool { + return apnsKeyFileName.MatchString(filepath.Base(path)) + }, 5*time.Second, 10*time.Millisecond) + if err != nil { + t.Fatal(err) + } + if path != fresh { + t.Errorf("picked %s", path) + } +} + +type fakeMessaging struct { + providers []map[string]any + requests []string + identities []map[string]any + failCreate bool + failList int +} + +func (f *fakeMessaging) ServeHTTP(w http.ResponseWriter, r *http.Request) { + f.requests = append(f.requests, r.Method+" "+r.URL.Path) + body, _ := io.ReadAll(r.Body) + var params map[string]any + _ = json.Unmarshal(body, ¶ms) + + switch { + case r.Method == "GET" && r.URL.Path == "/account/identities": + _ = json.NewEncoder(w).Encode(map[string]any{"total": len(f.identities), "identities": f.identities}) + case r.Method == "GET" && r.URL.Path == "/account": + _ = json.NewEncoder(w).Encode(map[string]any{"email": "dev@example.com"}) + case r.Method == "GET" && r.URL.Path == "/messaging/providers" && f.failList > 0: + f.failList-- + w.WriteHeader(http.StatusServiceUnavailable) + _ = json.NewEncoder(w).Encode(map[string]any{"message": "unavailable", "code": 503}) + case r.Method == "POST" && f.failCreate: + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(map[string]any{"message": "provider rejected", "code": 400}) + case r.Method == "GET" && r.URL.Path == "/messaging/providers": + kind := "" + for _, query := range r.URL.Query()["queries[]"] { + if strings.Contains(query, `"provider"`) { + kind = strings.Split(strings.Split(query, `"values":["`)[1], `"`)[0] + } + } + matching := []map[string]any{} + for _, provider := range f.providers { + if provider["provider"] == kind { + matching = append(matching, provider) + } + } + _ = json.NewEncoder(w).Encode(map[string]any{"total": len(matching), "providers": matching}) + case r.Method == "POST": + kind := strings.TrimPrefix(r.URL.Path, "/messaging/providers/") + provider := providerDocument(kind, "id"+string(rune('0'+len(f.providers))), params) + f.providers = append(f.providers, provider) + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(provider) + case r.Method == "PATCH": + parts := strings.Split(r.URL.Path, "/") + id := parts[len(parts)-1] + for index, provider := range f.providers { + if provider["$id"] == id { + f.providers[index] = providerDocument(provider["provider"].(string), id, params) + _ = json.NewEncoder(w).Encode(f.providers[index]) + + return + } + } + w.WriteHeader(http.StatusNotFound) + default: + w.WriteHeader(http.StatusNotFound) + } +} + +func providerDocument(kind, id string, params map[string]any) map[string]any { + credentials := map[string]any{} + options := map[string]any{} + for _, name := range []string{"authKey", "authKeyId", "teamId", "bundleId"} { + if value, ok := params[name]; ok { + credentials[name] = value + } + } + if sandbox, ok := params["sandbox"]; ok { + options["sandbox"] = sandbox + } + + return map[string]any{ + "$id": id, "name": params["name"], "provider": kind, "type": "push", + "enabled": params["enabled"], "credentials": credentials, "options": options, + } +} + +func newTestPushSetup(t *testing.T, server *httptest.Server, prompter prompt.Prompter, root string) (*pushSetup, *bytes.Buffer) { + t.Helper() + out := &bytes.Buffer{} + + return &pushSetup{ + api: client.New(server.URL, "test").SetProject("project"), + prompter: prompter, + out: out, + root: root, + keyDirs: []string{root}, + downloads: t.TempDir(), + open: func(string) {}, + }, out +} + +func TestInitApnsCreatesBothEnvironmentsAndEditsXcode(t *testing.T) { + messaging := &fakeMessaging{} + server := httptest.NewServer(messaging) + defer server.Close() + + root := t.TempDir() + writeFile(t, filepath.Join(root, "ios", "MyApp.xcodeproj", "project.pbxproj"), testPbxproj) + writeFile(t, filepath.Join(root, "ios", "MyApp", "MyApp.entitlements"), "\n\n\n") + writeFile(t, filepath.Join(root, "ios", "MyApp", "Info.plist"), "\n\n\n\n") + untouched := map[string]string{} + for _, path := range []string{"Admin/Admin.entitlements", "Admin/Info.plist", "Widget/Widget.entitlements", "Widget/Info.plist"} { + untouched[path] = "\n\n\n\n" + writeFile(t, filepath.Join(root, "ios", path), untouched[path]) + } + keyPath := testApnsKey(t, t.TempDir()) + + scripted := &prompt.Scripted{} + setup, _ := newTestPushSetup(t, server, scripted, root) + detected, _ := detectApple(root) + if err := setup.apns(apnsOptions{keyPath: keyPath, bundleID: myAppBundle}, detected); err != nil { + t.Fatal(err) + } + + if len(scripted.Asked) != 0 { + t.Errorf("asked %v", scripted.Asked) + } + if len(messaging.providers) != 2 { + t.Fatalf("providers = %v", messaging.providers) + } + for index, sandbox := range []bool{false, true} { + provider := messaging.providers[index] + credentials := provider["credentials"].(map[string]any) + if credentials["bundleId"] != myAppBundle || credentials["teamId"] != "ABCDE12345" || + credentials["authKeyId"] != "KEY1234567" || provider["options"].(map[string]any)["sandbox"] != sandbox || + provider["enabled"] != true { + t.Errorf("provider %d = %v", index, provider) + } + } + + entitlements, _ := os.ReadFile(filepath.Join(root, "ios", "MyApp", "MyApp.entitlements")) + if !strings.Contains(string(entitlements), "aps-environment\n\tdevelopment") { + t.Errorf("entitlements:\n%s", entitlements) + } + info, _ := os.ReadFile(filepath.Join(root, "ios", "MyApp", "Info.plist")) + if !strings.Contains(string(info), "remote-notification") { + t.Errorf("Info.plist:\n%s", info) + } + for path, original := range untouched { + if contents, _ := os.ReadFile(filepath.Join(root, "ios", path)); string(contents) != original { + t.Errorf("%s was edited:\n%s", path, contents) + } + } + + previous := map[string]any{} + for _, provider := range messaging.providers { + previous[provider["$id"].(string)] = provider["credentials"].(map[string]any)["authKey"] + } + rotated, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + rotatedPath := filepath.Join(t.TempDir(), "AuthKey_ROTATE1234.p8") + writeKey(t, rotatedPath, rotated) + if err := setup.apns(apnsOptions{keyPath: rotatedPath, bundleID: myAppBundle}, detected); err != nil { + t.Fatal(err) + } + if len(messaging.providers) != 2 { + t.Fatalf("providers after rotation = %v", messaging.providers) + } + for _, provider := range messaging.providers { + id := provider["$id"].(string) + credentials := provider["credentials"].(map[string]any) + old, kept := previous[id] + if !kept || credentials["authKeyId"] != "ROTATE1234" || credentials["authKey"] == old { + t.Errorf("provider %s after rotation = %v", id, credentials) + } + } + + messaging.requests = nil + if err := setup.apns(apnsOptions{bundleID: myAppBundle}, detected); err != nil { + t.Fatal(err) + } + if strings.Join(messaging.requests, ",") != "GET /messaging/providers" { + t.Errorf("already set up requests = %v", messaging.requests) + } +} + +func TestInitApnsUsesAKeyInTheProjectFolder(t *testing.T) { + messaging := &fakeMessaging{} + server := httptest.NewServer(messaging) + defer server.Close() + + root := t.TempDir() + testApnsKey(t, root) + writeFile(t, filepath.Join(root, "notes.p8"), "not a key") + + scripted := &prompt.Scripted{} + setup, out := newTestPushSetup(t, server, scripted, root) + if err := setup.apns(apnsOptions{bundleID: "com.example.app", teamID: "ABCDE12345"}, appleApp{}); err != nil { + t.Fatal(err) + } + + if len(scripted.Asked) != 0 { + t.Errorf("asked %v", scripted.Asked) + } + if !strings.Contains(out.String(), "Using AuthKey_KEY1234567.p8 from the project folder.") { + t.Errorf("output:\n%s", out.String()) + } + if len(messaging.providers) != 2 || messaging.providers[0]["credentials"].(map[string]any)["authKeyId"] != "KEY1234567" { + t.Errorf("providers = %v", messaging.providers) + } +} + +func TestBrowserFlowFindsAKeySavedIntoTheProjectFolder(t *testing.T) { + messaging := &fakeMessaging{} + server := httptest.NewServer(messaging) + defer server.Close() + + root := t.TempDir() + setup, _ := newTestPushSetup(t, server, &prompt.Scripted{}, root) + go func() { + time.Sleep(100 * time.Millisecond) + testApnsKey(t, root) + }() + + if err := setup.apns(apnsOptions{bundleID: "com.example.app", teamID: "ABCDE12345"}, appleApp{}); err != nil { + t.Fatal(err) + } + if len(messaging.providers) != 2 { + t.Errorf("providers = %v", messaging.providers) + } +} + +func TestKeysInAGitRepositoryMustBeIgnored(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git is not installed") + } + root := t.TempDir() + if err := exec.Command("git", "-C", root, "init", "-q").Run(); err != nil { + t.Skip("git init failed") + } + key := testApnsKey(t, root) + + out := &bytes.Buffer{} + setup := &pushSetup{out: out, keyDirs: []string{root}} + setup.checkKeyLocation(key) + if !strings.Contains(out.String(), "AuthKey_KEY1234567.p8 is inside a git repository and not ignored") { + t.Errorf("output:\n%s", out.String()) + } + + writeFile(t, filepath.Join(root, ".gitignore"), "*.p8\n") + out.Reset() + setup.checkKeyLocation(key) + if out.Len() != 0 { + t.Errorf("warned for an ignored key:\n%s", out.String()) + } +} + +// useAdapter makes adapter the only APNs key setup, under the default name, +// as an account with one team (APPLE12345) would see it. +func useAdapter(setup *pushSetup, adapter *apnstest.Adapter) *apnstest.Adapter { + adapter.AdapterName = defaultApnsSetup + if adapter.TeamID == "" { + adapter.TeamID = "APPLE12345" + } + setup.apnsAdapters = apns.NewRegistry(adapter) + + return adapter +} +func TestInitApnsCreatesTheKeyWithTheAppleID(t *testing.T) { + messaging := &fakeMessaging{} + server := httptest.NewServer(messaging) + defer server.Close() + + root := t.TempDir() + scripted := &prompt.Scripted{} + setup, out := newTestPushSetup(t, server, scripted, root) + appleKey := useAdapter(setup, &apnstest.Adapter{KeyID: "NEWKEY1234"}) + + options := apnsOptions{bundleID: "com.example.app", teamID: "ABCDE12345", createKey: true} + if err := setup.apns(options, appleApp{}); err != nil { + t.Fatal(err) + } + + if len(scripted.Asked) != 0 { + t.Errorf("asked %v", scripted.Asked) + } + if len(appleKey.Requests) != 1 || appleKey.Requests[0].TeamID != "ABCDE12345" || !strings.HasPrefix(appleKey.Requests[0].Name, "Appwrite Push ") { + t.Errorf("helper calls = %v", appleKey.Requests) + } + saved := filepath.Join(root, "AuthKey_NEWKEY1234.p8") + info, err := os.Stat(saved) + if err != nil { + t.Fatalf("key was not saved: %v", err) + } + if info.Mode().Perm() != 0o600 { + t.Errorf("key mode = %v", info.Mode().Perm()) + } + if len(messaging.providers) != 2 { + t.Fatalf("providers = %v", messaging.providers) + } + for _, provider := range messaging.providers { + credentials := provider["credentials"].(map[string]any) + if credentials["authKeyId"] != "NEWKEY1234" || credentials["teamId"] != "ABCDE12345" { + t.Errorf("provider = %v", provider) + } + } + if !strings.Contains(out.String(), "Created APNs key NEWKEY1234 on team ABCDE12345 and saved it to AuthKey_NEWKEY1234.p8.") { + t.Errorf("output:\n%s", out.String()) + } + + // A second run finds both providers and does not spend another key slot. + if err := setup.apns(options, appleApp{}); err != nil { + t.Fatal(err) + } + if len(appleKey.Requests) != 1 { + t.Errorf("created another key on rerun: %v", appleKey.Requests) + } +} + +func TestInitApnsOffersTheAppleIDWhenNoKeyIsFound(t *testing.T) { + messaging := &fakeMessaging{} + server := httptest.NewServer(messaging) + defer server.Close() + + scripted := &prompt.Scripted{Choices: map[string]string{ + "How would you like to provide the APNs auth key (.p8)?": "automatic", + }} + setup, _ := newTestPushSetup(t, server, scripted, t.TempDir()) + appleKey := useAdapter(setup, &apnstest.Adapter{KeyID: "NEWKEY1234"}) + + if err := setup.apns(apnsOptions{bundleID: "com.example.app", teamID: "ABCDE12345"}, appleApp{}); err != nil { + t.Fatal(err) + } + if len(appleKey.Requests) != 1 || len(messaging.providers) != 2 { + t.Errorf("calls = %v, providers = %v", appleKey.Requests, messaging.providers) + } +} + +func TestInitApnsStopsWhenCreateKeyFails(t *testing.T) { + messaging := &fakeMessaging{} + server := httptest.NewServer(messaging) + defer server.Close() + + root := t.TempDir() + // A key already in the project folder must not stand in for the one + // --create-key was asked to create. + testApnsKey(t, root) + scripted := &prompt.Scripted{} + setup, _ := newTestPushSetup(t, server, scripted, root) + useAdapter(setup, &apnstest.Adapter{Err: errors.New("could not reach Apple")}) + + err := setup.apns(apnsOptions{bundleID: "com.example.app", teamID: "ABCDE12345", createKey: true}, appleApp{}) + if err == nil || err.Error() != "could not create the APNs key with your Apple ID: could not reach Apple" { + t.Fatalf("err = %v", err) + } + if len(scripted.Asked) != 0 || len(messaging.providers) != 0 { + t.Errorf("asked %v, providers %v", scripted.Asked, messaging.providers) + } +} + +// choiceSequence answers the same choice differently each time it is asked. +type choiceSequence struct { + *prompt.Scripted + answers []string +} + +func (c *choiceSequence) Choice(question prompt.Choice) (string, error) { + c.Scripted.Asked = append(c.Scripted.Asked, question.Message) + answer := c.answers[0] + c.answers = c.answers[1:] + + return answer, nil +} + +func TestInitApnsFallsBackWhenTheChosenAppleSignInFails(t *testing.T) { + messaging := &fakeMessaging{} + server := httptest.NewServer(messaging) + defer server.Close() + + existing := testApnsKey(t, t.TempDir()) + scripted := &choiceSequence{ + Scripted: &prompt.Scripted{Texts: map[string]string{"Path to the APNs auth key (.p8)": existing}}, + answers: []string{"automatic", "file"}, + } + setup, out := newTestPushSetup(t, server, scripted, t.TempDir()) + useAdapter(setup, &apnstest.Adapter{Err: errors.New("could not reach Apple")}) + + if err := setup.apns(apnsOptions{bundleID: "com.example.app", teamID: "ABCDE12345"}, appleApp{}); err != nil { + t.Fatal(err) + } + if !strings.Contains(out.String(), "Could not create the APNs key with your Apple ID: could not reach Apple.") { + t.Errorf("output:\n%s", out.String()) + } + if len(messaging.providers) != 2 || messaging.providers[0]["credentials"].(map[string]any)["authKeyId"] != "KEY1234567" { + t.Errorf("providers = %v", messaging.providers) + } +} + +func TestInitApnsStopsWhenTheTeamHasNoFreeKeySlot(t *testing.T) { + messaging := &fakeMessaging{} + server := httptest.NewServer(messaging) + defer server.Close() + + root := t.TempDir() + setup, out := newTestPushSetup(t, server, &prompt.Scripted{}, root) + appleKey := &apnstest.Adapter{Err: &apns.MaxKeysError{Keys: []apns.ExistingKey{ + {ID: "OLDKEY1234", Name: "Firebase", CanRevoke: true}, + {ID: "OLDKEY5678", Name: "Expo", CanRevoke: true}, + }}} + useAdapter(setup, appleKey) + + err := setup.apns(apnsOptions{bundleID: "com.example.app", teamID: "ABCDE12345", createKey: true}, appleApp{}) + if err == nil || !strings.Contains(err.Error(), "--key-path") { + t.Fatalf("err = %v", err) + } + for _, line := range []string{"Firebase (OLDKEY1234)", "Expo (OLDKEY5678)", apnsKeysListPage} { + if !strings.Contains(out.String(), line) { + t.Errorf("output is missing %q:\n%s", line, out.String()) + } + } + if len(messaging.providers) != 0 { + t.Errorf("providers = %v", messaging.providers) + } + if entries, _ := os.ReadDir(root); len(entries) != 0 { + t.Errorf("wrote %v", entries) + } +} + +func TestInitApnsTakesTheTeamFromTheAppleSignIn(t *testing.T) { + messaging := &fakeMessaging{} + server := httptest.NewServer(messaging) + defer server.Close() + + scripted := &prompt.Scripted{} + setup, _ := newTestPushSetup(t, server, scripted, t.TempDir()) + appleKey := useAdapter(setup, &apnstest.Adapter{KeyID: "NEWKEY1234"}) + + if err := setup.apns(apnsOptions{bundleID: "com.example.app", createKey: true}, appleApp{}); err != nil { + t.Fatal(err) + } + if len(scripted.Asked) != 0 { + t.Errorf("asked %v", scripted.Asked) + } + if len(appleKey.Requests) != 1 || appleKey.Requests[0].TeamID != "" { + t.Errorf("helper calls = %v", appleKey.Requests) + } + for _, provider := range messaging.providers { + if provider["credentials"].(map[string]any)["teamId"] != "APPLE12345" { + t.Errorf("provider = %v", provider) + } + } +} + +func TestInitApnsAsksForTheTeamAfterAKeyFile(t *testing.T) { + messaging := &fakeMessaging{} + server := httptest.NewServer(messaging) + defer server.Close() + + existing := testApnsKey(t, t.TempDir()) + scripted := &prompt.Scripted{ + Choices: map[string]string{"How would you like to provide the APNs auth key (.p8)?": "file"}, + Texts: map[string]string{ + "Path to the APNs auth key (.p8)": existing, + "What is your Apple Developer team ID?": "ABCDE12345", + }, + } + setup, _ := newTestPushSetup(t, server, scripted, t.TempDir()) + useAdapter(setup, &apnstest.Adapter{KeyID: "NEWKEY1234"}) + + if err := setup.apns(apnsOptions{bundleID: "com.example.app"}, appleApp{}); err != nil { + t.Fatal(err) + } + want := []string{"How would you like to provide the APNs auth key (.p8)?", "Path to the APNs auth key (.p8)", "What is your Apple Developer team ID?"} + if strings.Join(scripted.Asked, "|") != strings.Join(want, "|") { + t.Errorf("asked %v", scripted.Asked) + } + if messaging.providers[0]["credentials"].(map[string]any)["teamId"] != "ABCDE12345" { + t.Errorf("providers = %v", messaging.providers) + } +} + +func TestInitApnsSetsUpOneEnvironment(t *testing.T) { + messaging := &fakeMessaging{} + server := httptest.NewServer(messaging) + defer server.Close() + + root := t.TempDir() + setup, out := newTestPushSetup(t, server, &prompt.Scripted{}, root) + appleKey := useAdapter(setup, &apnstest.Adapter{KeyID: "NEWKEY1234"}) + + options := apnsOptions{bundleID: "com.example.app", teamID: "ABCDE12345", createKey: true, environment: "sandbox"} + if err := setup.apns(options, appleApp{}); err != nil { + t.Fatal(err) + } + if len(appleKey.Requests) != 1 || appleKey.Requests[0].Environment != apns.EnvironmentSandbox { + t.Errorf("helper calls = %v", appleKey.Requests) + } + if len(messaging.providers) != 1 || messaging.providers[0]["options"].(map[string]any)["sandbox"] != true { + t.Fatalf("providers = %v", messaging.providers) + } + if !strings.Contains(out.String(), "APNs (sandbox) is set up for com.example.app.") { + t.Errorf("output:\n%s", out.String()) + } + + // The sandbox provider is all a sandbox run needs. + if err := setup.apns(options, appleApp{}); err != nil { + t.Fatal(err) + } + if len(appleKey.Requests) != 1 || !strings.Contains(out.String(), "APNs (sandbox) is already set up for com.example.app.") { + t.Errorf("calls = %v, output:\n%s", appleKey.Requests, out.String()) + } + + // Production still needs its own provider; the key already in the folder + // serves it. + if err := setup.apns(apnsOptions{bundleID: "com.example.app", teamID: "ABCDE12345", environment: "production"}, appleApp{}); err != nil { + t.Fatal(err) + } + if len(messaging.providers) != 2 || messaging.providers[1]["options"].(map[string]any)["sandbox"] != false { + t.Errorf("providers = %v", messaging.providers) + } +} + +func TestInitApnsRejectsAnUnknownEnvironment(t *testing.T) { + messaging := &fakeMessaging{} + server := httptest.NewServer(messaging) + defer server.Close() + + setup, _ := newTestPushSetup(t, server, &prompt.Scripted{}, t.TempDir()) + err := setup.apns(apnsOptions{bundleID: "com.example.app", environment: "staging"}, appleApp{}) + if err == nil || err.Error() != `--environment must be production, sandbox or all, not "staging"` { + t.Fatalf("err = %v", err) + } + if len(messaging.requests) != 0 { + t.Errorf("requests = %v", messaging.requests) + } +} + +func TestInitApnsPicksTheSetupFromTheFlagThenTheEnvironment(t *testing.T) { + messaging := &fakeMessaging{} + server := httptest.NewServer(messaging) + defer server.Close() + + setup, out := newTestPushSetup(t, server, &prompt.Scripted{}, t.TempDir()) + appwrite := &apnstest.Adapter{AdapterName: "appwrite", KeyID: "APPWRITE01", TeamID: "ABCDE12345"} + expo := &apnstest.Adapter{AdapterName: "expo", KeyID: "EXPOKEY001", TeamID: "ABCDE12345"} + setup.apnsAdapters = apns.NewRegistry(appwrite, expo) + env := map[string]string{ + "APPWRITE_APNS_SETUP": "expo", + "APPWRITE_APPLE_ID": "dev@example.com", + "APPWRITE_APPLE_PASSWORD": "secret", + } + setup.getenv = func(name string) string { return env[name] } + options := apnsOptions{bundleID: "com.example.app", createKey: true} + + if err := setup.apns(options, appleApp{}); err != nil { + t.Fatal(err) + } + if len(expo.Requests) != 1 || len(appwrite.Requests) != 0 { + t.Fatalf("expo = %d, appwrite = %d requests", len(expo.Requests), len(appwrite.Requests)) + } + request := expo.Requests[0] + if request.AppleID != "dev@example.com" || request.Password != "secret" || request.Asker == nil { + t.Errorf("request = %+v", request) + } + for _, line := range []string{"Signing in to the Apple Developer portal (expo).", "Using the Apple ID from APPWRITE_APPLE_ID.", "Using the Apple ID password from APPWRITE_APPLE_PASSWORD."} { + if !strings.Contains(out.String(), line) { + t.Errorf("output is missing %q:\n%s", line, out.String()) + } + } + if strings.Contains(out.String(), "secret") { + t.Errorf("printed the password:\n%s", out.String()) + } + + // Each run starts from no providers and a fresh folder: a key file is + // never overwritten. + fresh := func() { + messaging.providers = nil + setup.keyDirs = []string{t.TempDir()} + } + fresh() + options.setup = "appwrite" + if err := setup.apns(options, appleApp{}); err != nil { + t.Fatal(err) + } + if len(appwrite.Requests) != 1 || len(expo.Requests) != 1 { + t.Errorf("--provider did not win over %s", apnsSetupVariable) + } + + delete(env, "APPWRITE_APNS_SETUP") + options.setup = "" + fresh() + if err := setup.apns(options, appleApp{}); err != nil { + t.Fatal(err) + } + if len(appwrite.Requests) != 2 { + t.Errorf("the default is not %s", defaultApnsSetup) + } +} + +func TestInitApnsRejectsAMalformedBundleID(t *testing.T) { + messaging := &fakeMessaging{} + server := httptest.NewServer(messaging) + defer server.Close() + + setup, _ := newTestPushSetup(t, server, &prompt.Scripted{}, t.TempDir()) + err := setup.apns(apnsOptions{bundleID: "not a bundle id!", environment: "sandbox"}, appleApp{}) + if err == nil || !strings.Contains(err.Error(), "is not a bundle ID") { + t.Fatalf("err = %v", err) + } + if len(messaging.requests) != 0 { + t.Errorf("requests = %v", messaging.requests) + } +} + +func TestInitApnsRejectsAnUnknownSetupBeforeAnyRequest(t *testing.T) { + messaging := &fakeMessaging{} + server := httptest.NewServer(messaging) + defer server.Close() + + setup, _ := newTestPushSetup(t, server, &prompt.Scripted{}, t.TempDir()) + setup.apnsAdapters = apns.NewRegistry(&apnstest.Adapter{AdapterName: "appwrite"}) + err := setup.apns(apnsOptions{bundleID: "com.example.app", environment: "all", setup: "nope"}, appleApp{}) + if err == nil || !strings.Contains(err.Error(), `unknown APNs key setup "nope"`) { + t.Fatalf("err = %v", err) + } + if len(messaging.requests) != 0 { + t.Errorf("requests = %v", messaging.requests) + } +} + +func TestInitApnsReportsAnUnknownSetup(t *testing.T) { + messaging := &fakeMessaging{} + server := httptest.NewServer(messaging) + defer server.Close() + + setup, _ := newTestPushSetup(t, server, &prompt.Scripted{}, t.TempDir()) + setup.apnsAdapters = apns.NewRegistry(&apnstest.Adapter{AdapterName: "appwrite"}) + err := setup.apns(apnsOptions{bundleID: "com.example.app", teamID: "ABCDE12345", createKey: true, setup: "fastlane"}, appleApp{}) + if err == nil || !strings.Contains(err.Error(), `unknown APNs key setup "fastlane": use one of appwrite`) { + t.Fatalf("err = %v", err) + } + if len(messaging.providers) != 0 { + t.Errorf("providers = %v", messaging.providers) + } +} + +func TestInitApnsWithoutSetupsOnlyOffersTheOtherWays(t *testing.T) { + messaging := &fakeMessaging{} + server := httptest.NewServer(messaging) + defer server.Close() + + var offered []prompt.Option + scripted := &prompt.Scripted{Choices: map[string]string{"How would you like to provide the APNs auth key (.p8)?": "file"}, Texts: map[string]string{"Path to the APNs auth key (.p8)": testApnsKey(t, t.TempDir())}} + setup, _ := newTestPushSetup(t, server, optionsRecorder{scripted, &offered}, t.TempDir()) + setup.apnsAdapters = apns.NewRegistry() + + if err := setup.apns(apnsOptions{bundleID: "com.example.app", teamID: "ABCDE12345"}, appleApp{}); err != nil { + t.Fatal(err) + } + for _, option := range offered { + if option.Value == "automatic" { + t.Errorf("offered the Apple sign-in with no setup built in: %v", offered) + } + } + + messaging.providers = nil + err := setup.apns(apnsOptions{bundleID: "com.example.app", teamID: "ABCDE12345", createKey: true}, appleApp{}) + if err == nil || !strings.Contains(err.Error(), "this build has none") { + t.Errorf("err = %v", err) + } +} + +// optionsRecorder records the options of the key question. +type optionsRecorder struct { + *prompt.Scripted + options *[]prompt.Option +} + +func (r optionsRecorder) Choice(question prompt.Choice) (string, error) { + if question.Message == "How would you like to provide the APNs auth key (.p8)?" { + *r.options = question.Options + } + + return r.Scripted.Choice(question) +} + +func TestInitApnsUsesTheCreatedKeysID(t *testing.T) { + messaging := &fakeMessaging{} + server := httptest.NewServer(messaging) + defer server.Close() + + scripted := &prompt.Scripted{Choices: map[string]string{"How would you like to provide the APNs auth key (.p8)?": "automatic"}} + setup, out := newTestPushSetup(t, server, scripted, t.TempDir()) + useAdapter(setup, &apnstest.Adapter{KeyID: "NEWKEY1234"}) + + if err := setup.apns(apnsOptions{bundleID: "com.example.app", teamID: "ABCDE12345", keyID: "WRONGKEY12"}, appleApp{}); err != nil { + t.Fatal(err) + } + for _, provider := range messaging.providers { + if provider["credentials"].(map[string]any)["authKeyId"] != "NEWKEY1234" { + t.Errorf("provider = %v", provider) + } + } + if !strings.Contains(out.String(), "Using the created key's ID NEWKEY1234 instead of --key-id WRONGKEY12.") { + t.Errorf("output:\n%s", out.String()) + } + + command := newInitApnsCommand() + if err := command.ParseFlags([]string{"--create-key", "--key-id", "WRONGKEY12"}); err != nil { + t.Fatal(err) + } + if err := command.ValidateFlagGroups(); err == nil || !strings.Contains(err.Error(), "key-id") { + t.Errorf("--create-key with --key-id: %v", err) + } +} + +// askingAdapter asks one question before creating a key, as a sign-in does. +type askingAdapter struct{} + +func (askingAdapter) Name() string { return defaultApnsSetup } + +func (askingAdapter) PrepareAppID(_ context.Context, request apns.Request, _ apns.AppIDRequest) (apns.AppIDResult, error) { + return apns.AppIDResult{TeamID: request.TeamID, Registered: true, Push: true}, nil +} + +func (askingAdapter) CreateKey(_ context.Context, request apns.Request) (apns.Key, error) { + if _, err := request.Asker.Ask("Enter the 6-digit code sent to +1 •••12", false); err != nil { + return apns.Key{}, err + } + + return apns.Key{KeyID: "NEWKEY1234", TeamID: request.TeamID, P8: apnstest.P8()}, nil +} + +func TestInitApnsExplainsASignInWithoutATerminal(t *testing.T) { + messaging := &fakeMessaging{} + server := httptest.NewServer(messaging) + defer server.Close() + + setup, _ := newTestPushSetup(t, server, prompt.NonInteractive{}, t.TempDir()) + setup.apnsAdapters = apns.NewRegistry(askingAdapter{}) + + err := setup.apns(apnsOptions{bundleID: "com.example.app", teamID: "ABCDE12345", createKey: true}, appleApp{}) + if err == nil { + t.Fatal("no error without a terminal") + } + message := err.Error() + for _, part := range []string{`asked "Enter the 6-digit code sent to +1 •••12"`, "APPWRITE_APPLE_ID", "the two-factor code always needs one", "instead of --create-key"} { + if !strings.Contains(message, part) { + t.Errorf("message is missing %q: %s", part, message) + } + } + if strings.Contains(message, "Pass --key-path instead") { + t.Errorf("suggests a flag --create-key rejects: %s", message) + } +} + +func TestInitApnsResetSignsInAgainAndCreatesAKey(t *testing.T) { + messaging := &fakeMessaging{} + server := httptest.NewServer(messaging) + defer server.Close() + + setup, out := newTestPushSetup(t, server, &prompt.Scripted{}, t.TempDir()) + adapter := useAdapter(setup, &apnstest.Adapter{KeyID: "FIRSTKEY12"}) + options := apnsOptions{bundleID: "com.example.app", teamID: "ABCDE12345", createKey: true} + if err := setup.apns(options, appleApp{}); err != nil { + t.Fatal(err) + } + + // Already set up, yet --reset signs in again and creates a key, without + // --create-key or --force. + setup.keyDirs = []string{t.TempDir()} + adapter.KeyID = "SECONDKEY1" + if err := setup.apns(apnsOptions{bundleID: "com.example.app", teamID: "ABCDE12345", reset: true}, appleApp{}); err != nil { + t.Fatal(err) + } + if len(adapter.Requests) != 2 || adapter.Requests[0].Reset || !adapter.Requests[1].Reset { + t.Fatalf("requests = %+v", adapter.Requests) + } + for _, provider := range messaging.providers { + if provider["credentials"].(map[string]any)["authKeyId"] != "SECONDKEY1" { + t.Errorf("provider = %v", provider) + } + } + if !strings.Contains(out.String(), "Signing in again: the saved Apple sign-in is not used (--reset).") { + t.Errorf("output:\n%s", out.String()) + } + + command := newInitApnsCommand() + if err := command.ParseFlags([]string{"--reset", "--key-path", "AuthKey_KEY1234567.p8"}); err != nil { + t.Fatal(err) + } + if err := command.ValidateFlagGroups(); err == nil || !strings.Contains(err.Error(), "key-path") { + t.Errorf("--reset with --key-path: %v", err) + } +} + +const appIDSignInQuestion = "Sign in with your Apple ID to check the App ID for com.example.app?" + +func TestInitApnsChecksTheAppIDAfterCreatingAKey(t *testing.T) { + messaging := &fakeMessaging{} + server := httptest.NewServer(messaging) + defer server.Close() + + scripted := &prompt.Scripted{} + setup, out := newTestPushSetup(t, server, scripted, t.TempDir()) + adapter := useAdapter(setup, &apnstest.Adapter{ + KeyID: "NEWKEY1234", + AppIDResult: apns.AppIDResult{TeamID: "ABCDE12345", Registered: true, Push: true, Created: true, PushTurnedOn: true}, + }) + + options := apnsOptions{bundleID: "com.example.app", teamID: "ABCDE12345", reset: true} + if err := setup.apns(options, appleApp{}); err != nil { + t.Fatal(err) + } + if len(adapter.AppIDRequests) != 1 || adapter.AppIDRequests[0] != (apns.AppIDRequest{BundleID: "com.example.app", Ask: true}) { + t.Fatalf("App ID calls = %v", adapter.AppIDRequests) + } + // The key's saved sign-in is reused for its Apple ID: no question, and + // --reset does not sign in a second time. + if signIn := adapter.AppIDSignIns[0]; signIn.TeamID != "ABCDE12345" || signIn.Reset || !signIn.SavedSignInOnly || signIn.AppleID != "dev@example.com" { + t.Errorf("App ID sign-in = %+v", signIn) + } + if len(scripted.Asked) != 0 { + t.Errorf("asked %v", scripted.Asked) + } + if !strings.Contains(out.String(), "Registered com.example.app as an App ID with Push Notifications on team ABCDE12345.") { + t.Errorf("output:\n%s", out.String()) + } +} + +func TestInitApnsChecksTheAppIDWithTheSavedSignIn(t *testing.T) { + messaging := &fakeMessaging{} + server := httptest.NewServer(messaging) + defer server.Close() + + scripted := &prompt.Scripted{} + setup, out := newTestPushSetup(t, server, scripted, t.TempDir()) + adapter := useAdapter(setup, &apnstest.Adapter{ + AppIDResult: apns.AppIDResult{TeamID: "ABCDE12345", Registered: true, Push: true}, + }) + + keyPath := testApnsKey(t, t.TempDir()) + if err := setup.apns(apnsOptions{keyPath: keyPath, bundleID: "com.example.app", teamID: "ABCDE12345"}, appleApp{}); err != nil { + t.Fatal(err) + } + if len(adapter.AppIDRequests) != 1 || !adapter.AppIDRequests[0].Ask || !adapter.AppIDSignIns[0].SavedSignInOnly { + t.Fatalf("App ID calls = %v, sign-ins = %v", adapter.AppIDRequests, adapter.AppIDSignIns) + } + if len(scripted.Asked) != 0 || !strings.Contains(out.String(), "com.example.app is registered as an App ID with Push Notifications.") { + t.Errorf("asked %v, output:\n%s", scripted.Asked, out.String()) + } + + // An already set-up project is checked on every run. + if err := setup.apns(apnsOptions{bundleID: "com.example.app"}, appleApp{}); err != nil { + t.Fatal(err) + } + if len(adapter.AppIDRequests) != 2 || adapter.AppIDSignIns[1].TeamID != "ABCDE12345" { + t.Errorf("App ID calls = %v, sign-ins = %v", adapter.AppIDRequests, adapter.AppIDSignIns) + } +} + +func TestInitApnsAsksBeforeSigningInToCheckTheAppID(t *testing.T) { + messaging := &fakeMessaging{} + server := httptest.NewServer(messaging) + defer server.Close() + + scripted := &prompt.Scripted{} + setup, out := newTestPushSetup(t, server, scripted, t.TempDir()) + adapter := useAdapter(setup, &apnstest.Adapter{ + SignInNeeded: true, + AppIDResult: apns.AppIDResult{TeamID: "ABCDE12345", Registered: true, Push: true, PushTurnedOn: true}, + }) + keyPath := testApnsKey(t, t.TempDir()) + + // No saved sign-in and no answer: skipped, and the setup still succeeds. + if err := setup.apns(apnsOptions{keyPath: keyPath, bundleID: "com.example.app", teamID: "ABCDE12345"}, appleApp{}); err != nil { + t.Fatal(err) + } + if len(messaging.providers) != 2 || len(adapter.AppIDRequests) != 1 || strings.Join(scripted.Asked, "|") != appIDSignInQuestion { + t.Fatalf("providers = %v, App ID calls = %v, asked %v", messaging.providers, adapter.AppIDRequests, scripted.Asked) + } + if !strings.Contains(out.String(), "Skipped the App ID check. Run `appwrite init apns --register-app-id`") { + t.Errorf("output:\n%s", out.String()) + } + + // Agreeing signs in and checks. + scripted.Choices = map[string]string{appIDSignInQuestion: "check"} + if err := setup.apns(apnsOptions{bundleID: "com.example.app"}, appleApp{}); err != nil { + t.Fatal(err) + } + if len(adapter.AppIDRequests) != 3 || adapter.AppIDSignIns[2].SavedSignInOnly || !adapter.AppIDRequests[2].Ask { + t.Fatalf("App ID calls = %v, sign-ins = %v", adapter.AppIDRequests, adapter.AppIDSignIns) + } + if !strings.Contains(out.String(), "Turned on Push Notifications for the App ID com.example.app.") { + t.Errorf("output:\n%s", out.String()) + } +} + +func TestInitApnsRegisterAppIDSignsInAndDoesNotAsk(t *testing.T) { + messaging := &fakeMessaging{} + server := httptest.NewServer(messaging) + defer server.Close() + + scripted := &prompt.Scripted{} + setup, out := newTestPushSetup(t, server, scripted, t.TempDir()) + adapter := useAdapter(setup, &apnstest.Adapter{ + SignInNeeded: true, + AppIDResult: apns.AppIDResult{TeamID: "ABCDE12345", Registered: true, Push: true, Created: true, PushTurnedOn: true}, + }) + keyPath := testApnsKey(t, t.TempDir()) + options := apnsOptions{keyPath: keyPath, bundleID: "com.example.app", teamID: "ABCDE12345", registerAppID: true} + if err := setup.apns(options, appleApp{}); err != nil { + t.Fatal(err) + } + if len(adapter.AppIDRequests) != 1 || adapter.AppIDRequests[0].Ask || adapter.AppIDSignIns[0].SavedSignInOnly || len(scripted.Asked) != 0 { + t.Fatalf("App ID calls = %v, sign-ins = %v, asked %v", adapter.AppIDRequests, adapter.AppIDSignIns, scripted.Asked) + } + if !strings.Contains(out.String(), "Registered com.example.app as an App ID with Push Notifications on team ABCDE12345.") { + t.Errorf("output:\n%s", out.String()) + } + + // Asked for explicitly, a failure fails the command. + adapter.AppIDErr = errors.New("could not reach Apple") + err := setup.apns(apnsOptions{bundleID: "com.example.app", registerAppID: true}, appleApp{}) + if err == nil || !strings.Contains(err.Error(), "Could not check the App ID for com.example.app: could not reach Apple") { + t.Errorf("err = %v", err) + } +} + +func TestInitApnsSignsInAgainWhenTheNewKeysSignInIsNotSaved(t *testing.T) { + messaging := &fakeMessaging{} + server := httptest.NewServer(messaging) + defer server.Close() + + scripted := &prompt.Scripted{} + setup, _ := newTestPushSetup(t, server, scripted, t.TempDir()) + adapter := useAdapter(setup, &apnstest.Adapter{KeyID: "NEWKEY1234", SignInNeeded: true}) + + if err := setup.apns(apnsOptions{bundleID: "com.example.app", teamID: "ABCDE12345", createKey: true}, appleApp{}); err != nil { + t.Fatal(err) + } + // The person just signed in for the key: no "sign in to check?" question. + if len(adapter.AppIDSignIns) != 2 || !adapter.AppIDSignIns[0].SavedSignInOnly || adapter.AppIDSignIns[1].SavedSignInOnly || len(scripted.Asked) != 0 { + t.Errorf("sign-ins = %+v, asked %v", adapter.AppIDSignIns, scripted.Asked) + } +} diff --git a/templates/cli/internal/cmd/initpushdetect.go b/templates/cli/internal/cmd/initpushdetect.go new file mode 100644 index 0000000000..90b009f417 --- /dev/null +++ b/templates/cli/internal/cmd/initpushdetect.go @@ -0,0 +1,319 @@ +//go:build !browser + +package cmd + +import ( + "encoding/json" + "io/fs" + "os" + "path/filepath" + "regexp" + "sort" + "strings" +) + +type appleTarget struct { + Project string + BundleID string + TeamID string + Entitlements []string + InfoPlists []string +} + +type appleApp struct { + Projects []string + Targets []appleTarget + BundleIDs []string + TeamIDs []string + ExpoConfig string +} + +func (a appleApp) targets(bundleID string) []appleTarget { + var targets []appleTarget + for _, target := range a.Targets { + if target.BundleID == bundleID { + targets = append(targets, target) + } + } + + return targets +} + +func (a appleApp) teams(bundleID string) []string { + var teams []string + for _, target := range a.targets(bundleID) { + if target.TeamID != "" { + teams = appendUnique(teams, target.TeamID) + } + } + if len(teams) == 0 && len(a.TeamIDs) == 1 { + return a.TeamIDs + } + + return teams +} + +const detectDepth = 4 + +var skippedDirectories = map[string]bool{ + "node_modules": true, + "Pods": true, + ".git": true, + "build": true, + "DerivedData": true, + ".dart_tool": true, + ".gradle": true, + "vendor": true, + ".expo": true, +} + +func walkProject(root string, visit func(path string, entry fs.DirEntry) error) error { + root = filepath.Clean(root) + base := strings.Count(root, string(filepath.Separator)) + + return filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error { + if err != nil { + if path == root { + return err + } + + return nil + } + if entry.IsDir() && path != root { + if skippedDirectories[entry.Name()] { + return filepath.SkipDir + } + if strings.Count(path, string(filepath.Separator))-base > detectDepth { + return filepath.SkipDir + } + } + + return visit(path, entry) + }) +} + +const applicationProductType = "com.apple.product-type.application" + +var ( + pbxObject = regexp.MustCompile(`(?ms)^\t\t([0-9A-Fa-f]{24})(?: /\*.*?\*/)? = \{\n(.*?)\n\t\t\};`) + pbxIsa = regexp.MustCompile(`(?m)^\t\t\tisa = (\w+);`) + pbxProductType = regexp.MustCompile(`(?m)^\t\t\tproductType = "?([^";]+)"?;`) + pbxConfigList = regexp.MustCompile(`(?m)^\t\t\tbuildConfigurationList = ([0-9A-Fa-f]{24})`) + pbxConfigs = regexp.MustCompile(`(?s)buildConfigurations = \((.*?)\);`) + pbxReference = regexp.MustCompile(`[0-9A-Fa-f]{24}`) + pbxBuildSettings = regexp.MustCompile(`(?s)buildSettings = \{(.*?)\n\t\t\t\};`) + pbxSetting = regexp.MustCompile(`(?m)^\s*([A-Za-z_][A-Za-z0-9_]*) = (.*?);\s*$`) + appleTeamID = regexp.MustCompile(`^[A-Z0-9]{10}$`) +) + +func pbxValue(value string) string { + value = strings.TrimSpace(value) + if len(value) >= 2 && strings.HasPrefix(value, `"`) && strings.HasSuffix(value, `"`) { + value = value[1 : len(value)-1] + } + + return value +} + +func rfc1034(value string) string { + var builder strings.Builder + for _, character := range value { + switch { + case character >= 'a' && character <= 'z', character >= 'A' && character <= 'Z', + character >= '0' && character <= '9', character == '-', character == '.': + builder.WriteRune(character) + default: + builder.WriteRune('-') + } + } + + return builder.String() +} + +func resolveBundleID(bundleID string, settings map[string]string) string { + name := settings["PRODUCT_NAME"] + if name != "" && !strings.Contains(name, "$(") { + bundleID = strings.ReplaceAll(bundleID, "$(PRODUCT_NAME:rfc1034identifier)", rfc1034(name)) + bundleID = strings.ReplaceAll(bundleID, "${PRODUCT_NAME:rfc1034identifier}", rfc1034(name)) + bundleID = strings.ReplaceAll(bundleID, "$(PRODUCT_NAME)", name) + bundleID = strings.ReplaceAll(bundleID, "${PRODUCT_NAME}", name) + } + if strings.Contains(bundleID, "$") { + return "" + } + + return bundleID +} + +func projectPath(projectDir, value string) string { + value = strings.TrimPrefix(value, "$(SRCROOT)/") + value = strings.TrimPrefix(value, "${SRCROOT}/") + value = strings.TrimPrefix(value, "$(PROJECT_DIR)/") + if strings.Contains(value, "$") { + return "" + } + + return filepath.Join(projectDir, value) +} + +func pbxSettings(body string) map[string]string { + settings := map[string]string{} + block := pbxBuildSettings.FindStringSubmatch(body) + if block == nil { + return settings + } + for _, setting := range pbxSetting.FindAllStringSubmatch(block[1], -1) { + settings[setting[1]] = pbxValue(setting[2]) + } + + return settings +} + +func readXcodeProject(app *appleApp, xcodeproj string) { + contents, err := os.ReadFile(filepath.Join(xcodeproj, "project.pbxproj")) + if err != nil { + return + } + app.Projects = append(app.Projects, xcodeproj) + projectDir := filepath.Dir(xcodeproj) + + objects := map[string]string{} + var applications []string + for _, object := range pbxObject.FindAllStringSubmatch(string(contents), -1) { + objects[object[1]] = object[2] + isa := pbxIsa.FindStringSubmatch(object[2]) + productType := pbxProductType.FindStringSubmatch(object[2]) + if isa != nil && isa[1] == "PBXNativeTarget" && productType != nil && productType[1] == applicationProductType { + applications = append(applications, object[2]) + } + } + + for _, application := range applications { + list := pbxConfigList.FindStringSubmatch(application) + if list == nil { + continue + } + configs := pbxConfigs.FindStringSubmatch(objects[list[1]]) + if configs == nil { + continue + } + + byBundle := map[string]*appleTarget{} + var order []string + for _, reference := range pbxReference.FindAllString(configs[1], -1) { + settings := pbxSettings(objects[reference]) + bundleID := resolveBundleID(settings["PRODUCT_BUNDLE_IDENTIFIER"], settings) + if bundleID == "" { + continue + } + target, ok := byBundle[bundleID] + if !ok { + target = &appleTarget{Project: xcodeproj, BundleID: bundleID} + byBundle[bundleID] = target + order = append(order, bundleID) + } + if team := settings["DEVELOPMENT_TEAM"]; appleTeamID.MatchString(team) && target.TeamID == "" { + target.TeamID = team + } + if path := projectPath(projectDir, settings["CODE_SIGN_ENTITLEMENTS"]); settings["CODE_SIGN_ENTITLEMENTS"] != "" && path != "" { + target.Entitlements = appendUnique(target.Entitlements, path) + } + if path := projectPath(projectDir, settings["INFOPLIST_FILE"]); settings["INFOPLIST_FILE"] != "" && path != "" { + target.InfoPlists = appendUnique(target.InfoPlists, path) + } + } + for _, bundleID := range order { + app.Targets = append(app.Targets, *byBundle[bundleID]) + } + } +} + +type expoConfig struct { + Expo struct { + IOS struct { + BundleIdentifier string `json:"bundleIdentifier"` + AppleTeamID string `json:"appleTeamId"` + } `json:"ios"` + } `json:"expo"` +} + +func readExpoConfig(root string) (expoConfig, string) { + for _, name := range []string{"app.json", "app.config.json"} { + path := filepath.Join(root, name) + contents, err := os.ReadFile(path) + if err != nil { + continue + } + var config expoConfig + if json.Unmarshal(contents, &config) == nil { + return config, path + } + } + + return expoConfig{}, "" +} + +func detectApple(root string) (appleApp, error) { + app := appleApp{} + + err := walkProject(root, func(path string, entry fs.DirEntry) error { + if entry.IsDir() && strings.HasSuffix(entry.Name(), ".xcodeproj") { + readXcodeProject(&app, path) + + return filepath.SkipDir + } + + return nil + }) + if err != nil { + return app, err + } + + config, path := readExpoConfig(root) + if bundleID := config.Expo.IOS.BundleIdentifier; bundleID != "" { + app.ExpoConfig = path + team := "" + if appleTeamID.MatchString(config.Expo.IOS.AppleTeamID) { + team = config.Expo.IOS.AppleTeamID + } + matched := false + for index := range app.Targets { + if app.Targets[index].BundleID == bundleID { + matched = true + if app.Targets[index].TeamID == "" { + app.Targets[index].TeamID = team + } + } + } + if !matched { + app.Targets = append(app.Targets, appleTarget{BundleID: bundleID, TeamID: team}) + } + } + + for _, target := range app.Targets { + app.BundleIDs = appendUnique(app.BundleIDs, target.BundleID) + if target.TeamID != "" { + app.TeamIDs = appendUnique(app.TeamIDs, target.TeamID) + } + } + sort.Strings(app.BundleIDs) + + return app, nil +} + +var ( + gradleApplicationPlugin = regexp.MustCompile(`com\.android\.application`) + gradleApplicationID = regexp.MustCompile(`\bapplicationId\s*(?:=\s*)?["']([^"']+)["']`) + gradleNamespace = regexp.MustCompile(`\bnamespace\s*(?:=\s*)?["']([^"']+)["']`) + dartProjectID = regexp.MustCompile(`projectId:\s*'([^']+)'`) +) + +func appendUnique(values []string, value string) []string { + for _, existing := range values { + if existing == value { + return values + } + } + + return append(values, value) +} From 461f4def54664d9c780150a15577c3bba98a4853 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Wed, 14 Oct 2026 13:51:19 +0530 Subject: [PATCH 3/9] feat(cli): add the Apple platform in init apns, and say what else push needs Without an Apple platform for the bundle ID the API refuses the app's requests as an unknown origin, device registration included, and init apns left that to the console. It now adds the platform when the project has none for the bundle ID, and tells how to add it when it can't. It also ends with what init apns doesn't set up: the Appwrite push provider when the project has none enabled, and the Notification Service Extension with its App Group for background and swiped-away delivery. --- templates/cli/internal/cmd/initpush.go | 53 ++++++++++++++++++++- templates/cli/internal/cmd/initpush_test.go | 37 +++++++++++++- 2 files changed, 87 insertions(+), 3 deletions(-) diff --git a/templates/cli/internal/cmd/initpush.go b/templates/cli/internal/cmd/initpush.go index 5a41d21049..5771290e3a 100644 --- a/templates/cli/internal/cmd/initpush.go +++ b/templates/cli/internal/cmd/initpush.go @@ -245,8 +245,11 @@ func (s *pushSetup) apns(options apnsOptions, detected appleApp) error { output.Success(s.out, "APNs%s is already set up for %s.", environmentLabel(environment), bundleID) output.Hint(s.out, "Pass --force or --key-path to replace the key.") s.configureXcode(detected, bundleID) + s.ensureApplePlatform(bundleID) + err := s.prepareAppID(provisionedTeam(production, sandbox, detected.teams(bundleID)), bundleID, options, false) + s.apnsNextSteps() - return s.prepareAppID(provisionedTeam(production, sandbox, detected.teams(bundleID)), bundleID, options, false) + return err } teams := detected.teams(bundleID) @@ -343,8 +346,54 @@ func (s *pushSetup) apns(options apnsOptions, detected appleApp) error { s.configureXcode(detected, bundleID) output.Success(s.out, "APNs%s is set up for %s.", environmentLabel(environment), bundleID) + s.ensureApplePlatform(bundleID) // A key created a moment ago left a fresh sign-in to check the App ID with. - return s.prepareAppID(teamID, bundleID, options, createdKeyID != "") + err = s.prepareAppID(teamID, bundleID, options, createdKeyID != "") + s.apnsNextSteps() + + return err +} + +// ensureApplePlatform adds the bundle ID as an Apple platform of the project: without one, the +// API refuses the app's requests as an unknown origin, device registration included. +func (s *pushSetup) ensureApplePlatform(bundleID string) { + manual := func() { + output.Hint(s.out, "Add an Apple platform with the bundle ID %s to the project in the Appwrite console, or the app's requests are refused as an unknown origin.", bundleID) + } + var list struct { + Platforms []struct { + Type string `json:"type"` + Key string `json:"key"` + BundleIdentifier string `json:"bundleIdentifier"` + } `json:"platforms"` + } + if err := s.api.Call("GET", "/project/platforms", nil, &list); err != nil { + output.Warn(s.out, "Could not check the project's platforms: %s.", err) + manual() + + return + } + for _, platform := range list.Platforms { + if strings.HasPrefix(platform.Type, "apple") && (platform.BundleIdentifier == bundleID || platform.Key == bundleID) { + return + } + } + body := map[string]any{"platformId": "unique()", "name": "iOS (" + bundleID + ")", "bundleIdentifier": bundleID} + if err := s.api.Call("POST", "/project/platforms/apple", body, nil); err != nil { + output.Warn(s.out, "Could not add the Apple platform %s: %s.", bundleID, err) + manual() + + return + } + output.Success(s.out, "Added the Apple platform %s to the project.", bundleID) +} + +// apnsNextSteps says what else push on iOS needs beyond what init apns sets up. +func (s *pushSetup) apnsNextSteps() { + if appwrite, err := s.providers("appwrite"); err == nil && findProvider(appwrite, func(provider messagingProvider) bool { return provider.Enabled }) == nil { + output.Hint(s.out, "Enable the Appwrite push provider in the project: messages reach the app over it, and APNs only wakes the app.") + } + output.Hint(s.out, "For messages while the app is in the background or swiped away, add a Notification Service Extension whose class extends AppwriteNotificationService, and put the app and the extension in one App Group, named by AppwritePushAppGroup in both Info.plists.") } // apnsKey finds or creates the APNs key and returns its path. With diff --git a/templates/cli/internal/cmd/initpush_test.go b/templates/cli/internal/cmd/initpush_test.go index 8aa20b3f77..167c0142fd 100644 --- a/templates/cli/internal/cmd/initpush_test.go +++ b/templates/cli/internal/cmd/initpush_test.go @@ -291,6 +291,7 @@ func TestWaitForKeyFileIgnoresEarlierFiles(t *testing.T) { } type fakeMessaging struct { + platforms []map[string]any providers []map[string]any requests []string identities []map[string]any @@ -305,6 +306,13 @@ func (f *fakeMessaging) ServeHTTP(w http.ResponseWriter, r *http.Request) { _ = json.Unmarshal(body, ¶ms) switch { + case r.Method == "GET" && r.URL.Path == "/project/platforms": + _ = json.NewEncoder(w).Encode(map[string]any{"total": len(f.platforms), "platforms": f.platforms}) + case r.Method == "POST" && r.URL.Path == "/project/platforms/apple": + platform := map[string]any{"$id": "platform" + string(rune('0'+len(f.platforms))), "type": "apple", "name": params["name"], "bundleIdentifier": params["bundleIdentifier"]} + f.platforms = append(f.platforms, platform) + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(platform) case r.Method == "GET" && r.URL.Path == "/account/identities": _ = json.NewEncoder(w).Encode(map[string]any{"total": len(f.identities), "identities": f.identities}) case r.Method == "GET" && r.URL.Path == "/account": @@ -468,9 +476,36 @@ func TestInitApnsCreatesBothEnvironmentsAndEditsXcode(t *testing.T) { if err := setup.apns(apnsOptions{bundleID: myAppBundle}, detected); err != nil { t.Fatal(err) } - if strings.Join(messaging.requests, ",") != "GET /messaging/providers" { + // A rerun only reads: the providers, the platform the first run added, and the Appwrite push provider. + if strings.Join(messaging.requests, ",") != "GET /messaging/providers,GET /project/platforms,GET /messaging/providers" { t.Errorf("already set up requests = %v", messaging.requests) } + if len(messaging.platforms) != 1 || messaging.platforms[0]["bundleIdentifier"] != myAppBundle { + t.Errorf("platforms = %v", messaging.platforms) + } +} + +func TestInitApnsAddsTheApplePlatformAndSaysWhatElseIsNeeded(t *testing.T) { + web := map[string]any{"$id": "web", "type": "web", "hostname": "example.com"} + messaging := &fakeMessaging{platforms: []map[string]any{web}} + server := httptest.NewServer(messaging) + defer server.Close() + + root := t.TempDir() + setup, out := newTestPushSetup(t, server, &prompt.Scripted{}, root) + err := setup.apns(apnsOptions{keyPath: testApnsKey(t, root), keyID: "ABCDE12345", teamID: "ABCDE12345", bundleID: "com.example.app", environment: "sandbox"}, appleApp{}) + if err != nil { + t.Fatal(err) + } + if len(messaging.platforms) != 2 || messaging.platforms[1]["bundleIdentifier"] != "com.example.app" { + t.Fatalf("platforms = %v", messaging.platforms) + } + text := out.String() + for _, part := range []string{"Added the Apple platform com.example.app", "Enable the Appwrite push provider", "AppwriteNotificationService"} { + if !strings.Contains(text, part) { + t.Errorf("output is missing %q:\n%s", part, text) + } + } } func TestInitApnsUsesAKeyInTheProjectFolder(t *testing.T) { From 758419f6647ac2888c11bdd5cd86ddffe70f29ec Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Wed, 14 Oct 2026 16:02:27 +0530 Subject: [PATCH 4/9] test(cli): e2e for init apns against the mock API The mock API keeps messaging providers and project platforms per project, so the harness can run the built binary on a project folder: the first run creates both APNs providers and the Apple platform and prints the next steps, a rerun changes nothing, and a malformed bundle ID, an unknown --provider and an unknown --environment fail. --- mock-server/app/http.php | 120 ++++++++++++++++++++++++++++++++ tests/e2e/CLIGo126Test.php | 1 + tests/e2e/languages/cli/main.go | 83 ++++++++++++++++++++++ 3 files changed, 204 insertions(+) diff --git a/mock-server/app/http.php b/mock-server/app/http.php index 1a7d67fccb..fe2b76ecb5 100644 --- a/mock-server/app/http.php +++ b/mock-server/app/http.php @@ -1052,6 +1052,126 @@ ]); }); +/** + * Messaging providers and project platforms, for `appwrite init apns`. Kept per project in a file, + * since the workers share no memory, so a second run sees what the first created. + */ +function apnsMockState(Request $request, callable $change): array +{ + $project = \preg_replace('/[^A-Za-z0-9_-]/', '', (string) $request->getHeader('x-appwrite-project', 'default')); + $handle = \fopen(APP_STORAGE_CACHE . '/apns-' . $project . '.json', 'c+'); + if ($handle === false) { + throw new Exception(Exception::GENERAL_MOCK, 'Failed to open the APNs mock state', 500); + } + \flock($handle, LOCK_EX); + $state = \json_decode((string) \stream_get_contents($handle), true) ?: ['providers' => [], 'platforms' => []]; + $result = $change($state); + \ftruncate($handle, 0); + \rewind($handle); + \fwrite($handle, \json_encode($state)); + \flock($handle, LOCK_UN); + \fclose($handle); + + return $result; +} + +function apnsMockProvider(string $id, array $params): array +{ + return [ + '$id' => $id, + 'name' => $params['name'] ?? '', + 'provider' => 'apns', + 'type' => 'push', + 'enabled' => (bool) ($params['enabled'] ?? true), + 'credentials' => [ + 'authKeyId' => $params['authKeyId'] ?? '', + 'teamId' => $params['teamId'] ?? '', + 'bundleId' => $params['bundleId'] ?? '', + ], + 'options' => ['sandbox' => (bool) ($params['sandbox'] ?? false)], + ]; +} + +App::get('/v1/messaging/providers') + ->inject('request') + ->inject('response') + ->action(function (Request $request, UtopiaSwooleResponse $response) { + $kind = ''; + foreach ((array) $request->getParam('queries', []) as $query) { + $decoded = \json_decode((string) $query, true); + if (($decoded['attribute'] ?? '') === 'provider') { + $kind = (string) ($decoded['values'][0] ?? ''); + } + } + $providers = apnsMockState($request, fn (array &$state) => \array_values(\array_filter( + $state['providers'], + fn (array $provider) => $kind === '' || $provider['provider'] === $kind + ))); + $response->json(['total' => \count($providers), 'providers' => $providers]); + }); + +App::post('/v1/messaging/providers/apns') + ->inject('request') + ->inject('response') + ->action(function (Request $request, UtopiaSwooleResponse $response) { + $params = $request->getParams(); + $provider = apnsMockState($request, function (array &$state) use ($params) { + $provider = apnsMockProvider('provider' . \count($state['providers']), $params); + $state['providers'][] = $provider; + return $provider; + }); + $response->setStatusCode(Response::STATUS_CODE_CREATED)->json($provider); + }); + +App::patch('/v1/messaging/providers/apns/:providerId') + ->param('providerId', '', new Text(36), 'Provider ID.') + ->inject('request') + ->inject('response') + ->action(function (string $providerId, Request $request, UtopiaSwooleResponse $response) { + $params = $request->getParams(); + $provider = apnsMockState($request, function (array &$state) use ($providerId, $params) { + foreach ($state['providers'] as $index => $provider) { + if ($provider['$id'] === $providerId) { + return $state['providers'][$index] = apnsMockProvider($providerId, $params); + } + } + return null; + }); + if ($provider === null) { + throw new Exception(Exception::GENERAL_MOCK, 'Provider not found', 404); + } + $response->json($provider); + }); + +App::get('/v1/project/platforms') + ->inject('request') + ->inject('response') + ->action(function (Request $request, UtopiaSwooleResponse $response) { + $platforms = apnsMockState($request, fn (array &$state) => $state['platforms']); + $response->json(['total' => \count($platforms), 'platforms' => $platforms]); + }); + +App::post('/v1/project/platforms/apple') + ->inject('request') + ->inject('response') + ->action(function (Request $request, UtopiaSwooleResponse $response) { + $params = $request->getParams(); + if (($params['bundleIdentifier'] ?? '') === '') { + throw new Exception(Exception::GENERAL_MOCK, 'Missing bundleIdentifier', 400); + } + $platform = apnsMockState($request, function (array &$state) use ($params) { + $platform = [ + '$id' => 'platform' . \count($state['platforms']), + 'type' => 'apple', + 'name' => $params['name'] ?? '', + 'bundleIdentifier' => $params['bundleIdentifier'], + ]; + $state['platforms'][] = $platform; + return $platform; + }); + $response->setStatusCode(Response::STATUS_CODE_CREATED)->json($platform); + }); + App::shutdown() ->groups(['mock']) ->inject('utopia') diff --git a/tests/e2e/CLIGo126Test.php b/tests/e2e/CLIGo126Test.php index 1849f9f3d3..c14ac81d3a 100644 --- a/tests/e2e/CLIGo126Test.php +++ b/tests/e2e/CLIGo126Test.php @@ -49,6 +49,7 @@ final class CLIGo126Test extends Base ...Base::UPLOAD_RESPONSE, ...Base::CLI_HEADERS_RESPONSES, 'CLI_TYPES_DEPENDENCIES:passed', + 'CLI_INIT_APNS:passed', 'CLI_CONFORMANCE:passed', ]; diff --git a/tests/e2e/languages/cli/main.go b/tests/e2e/languages/cli/main.go index c0c85d5982..62b21b13e5 100644 --- a/tests/e2e/languages/cli/main.go +++ b/tests/e2e/languages/cli/main.go @@ -16,6 +16,11 @@ package main import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "encoding/pem" "errors" "fmt" "os" @@ -94,6 +99,8 @@ func main() { harness.typesDependencies() fmt.Println("CLI_TYPES_DEPENDENCIES:passed") + harness.initApns() + fmt.Println("CLI_INIT_APNS:passed") fmt.Println("CLI_CONFORMANCE:passed") } @@ -290,6 +297,82 @@ func (h *harness) value(args ...string) string { return "" } +// initApns sets up APNs for a project folder against the mock API: both providers and the Apple +// platform on the first run, nothing new on a rerun, and nothing written for bad input. The App ID +// check needs an Apple sign-in, and without a terminal it is skipped. +func (h *harness) initApns() { + dir, err := os.MkdirTemp("", "appwrite-cli-apns-") + if err != nil { + fail(err) + } + defer os.RemoveAll(dir) + + write := func(name, contents string) { + if err := os.WriteFile(filepath.Join(dir, name), []byte(contents), 0o600); err != nil { + fail(err) + } + } + write("appwrite.config.json", `{"projectId":"apns-e2e","endpoint":"http://mockapi/v1"}`) + + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + fail(err) + } + der, err := x509.MarshalPKCS8PrivateKey(key) + if err != nil { + fail(err) + } + write("AuthKey_ABCDE12345.p8", string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}))) + + absolute, err := filepath.Abs(binary) + if err != nil { + fail(err) + } + + run := func(wantSuccess bool, args ...string) string { + command := h.command(append([]string{"init", "apns"}, args...)...) + command.Dir = dir + command.Path = absolute + var combined strings.Builder + command.Stdout = &combined + command.Stderr = &combined + err := command.Run() + if (err == nil) != wantSuccess { + fail(fmt.Errorf("`init apns %s` exit = %v, want success %v:\n%s", strings.Join(args, " "), err, wantSuccess, combined.String())) + } + + return combined.String() + } + expect := func(output string, want ...string) { + for _, part := range want { + if !strings.Contains(output, part) { + fail(fmt.Errorf("init apns output is missing %q:\n%s", part, output)) + } + } + } + + first := run(true, "--key-path", "AuthKey_ABCDE12345.p8", "--team-id", "ABCDE12345", "--bundle-id", "io.appwrite.e2e") + expect(first, + "Created provider APNs (io.appwrite.e2e)", + "Created provider APNs sandbox (io.appwrite.e2e)", + "APNs is set up for io.appwrite.e2e", + "Added the Apple platform io.appwrite.e2e to the project", + "Skipped the App ID check", + "Enable the Appwrite push provider", + "AppwriteNotificationService", + ) + + again := run(true, "--bundle-id", "io.appwrite.e2e") + expect(again, "APNs is already set up for io.appwrite.e2e") + if strings.Contains(again, "Added the Apple platform") || strings.Contains(again, "Created provider") { + fail(fmt.Errorf("a rerun changed the project:\n%s", again)) + } + + expect(run(false, "--bundle-id", "not a bundle id!", "--key-path", "AuthKey_ABCDE12345.p8", "--team-id", "ABCDE12345"), "is not a bundle ID") + expect(run(false, "--bundle-id", "io.appwrite.e2e", "--provider", "nope"), `unknown APNs key setup "nope"`) + expect(run(false, "--bundle-id", "io.appwrite.e2e", "--environment", "staging"), "--environment must be production, sandbox or all") +} + func fail(err error) { fmt.Fprintln(os.Stderr, "conformance harness:", err) os.Exit(1) From 79908afa197598752bb02b91360f0bdf40432d25 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Wed, 14 Oct 2026 16:04:17 +0530 Subject: [PATCH 5/9] test(cli): give each init apns e2e run its own project The mock keeps providers and platforms in a volume that docker compose down leaves in place, so a fixed project made every run after the first see the earlier run's providers and fail its first-run checks. --- tests/e2e/languages/cli/main.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/e2e/languages/cli/main.go b/tests/e2e/languages/cli/main.go index 62b21b13e5..0d781a551b 100644 --- a/tests/e2e/languages/cli/main.go +++ b/tests/e2e/languages/cli/main.go @@ -312,7 +312,9 @@ func (h *harness) initApns() { fail(err) } } - write("appwrite.config.json", `{"projectId":"apns-e2e","endpoint":"http://mockapi/v1"}`) + // A project of its own each run: the mock keeps providers and platforms across runs. + project := "apns-e2e-" + filepath.Base(dir)[len("appwrite-cli-apns-"):] + write("appwrite.config.json", `{"projectId":"`+project+`","endpoint":"http://mockapi/v1"}`) key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) if err != nil { From bb2fdcb8708e1bed64f3d5132aca8aa5ba2aa0db Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Wed, 14 Oct 2026 16:18:46 +0530 Subject: [PATCH 6/9] feat(cli): point the Apple sign-in at another server with APPWRITE_APPLE_HOST The e2e tests run init apns against a mock of Apple, so the sign-in, App Store Connect and Developer portal hosts can all be set to one base URL. Unset, they stay Apple's servers. --- templates/cli/internal/cmd/initpush.go | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/templates/cli/internal/cmd/initpush.go b/templates/cli/internal/cmd/initpush.go index 5771290e3a..19a65c4101 100644 --- a/templates/cli/internal/cmd/initpush.go +++ b/templates/cli/internal/cmd/initpush.go @@ -455,12 +455,20 @@ const ( // The Apple ID and password, when set, answer the sign-in questions. appleIDVariable = "APPWRITE_APPLE_ID" applePasswordVariable = "APPWRITE_APPLE_PASSWORD" + // appleHostVariable points Apple's sign-in and Developer portal at one + // other server, a mock of Apple in the e2e tests. + appleHostVariable = "APPWRITE_APPLE_HOST" ) // apnsAdapters lists the APNs key setups this build offers. Each lives in its // own package under internal/apns and is added here. func apnsAdapters() *apns.Registry { - return apns.NewRegistry(appwrite.New(), expo.New(), fastlane.New()) + adapter := appwrite.New() + if host := os.Getenv(appleHostVariable); host != "" { + adapter.Hosts = appwrite.Hosts{Idmsa: host, AppStoreConnect: host, Portal: host} + } + + return apns.NewRegistry(adapter, expo.New(), fastlane.New()) } // apnsAdapter picks the setup: --provider, then APPWRITE_APNS_SETUP, then From ec32541b4b8798fd58b291a89d9da7feb8ac32f5 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Wed, 14 Oct 2026 16:18:46 +0530 Subject: [PATCH 7/9] test(cli): e2e for init apns against a mock of Apple The mock API gains Apple's sign-in, session, team, key and App ID endpoints under /apple, kept per Apple ID. The harness signs in with an Apple ID and password, creates the key and registers the App ID, then reuses the saved sign-in to check the App ID on a rerun, turn on Push Notifications for an existing App ID, and create keys until the team has no free slot. --- mock-server/app/http.php | 230 +++++++++++++++++++++++++++++++- tests/e2e/CLIGo126Test.php | 1 + tests/e2e/languages/cli/main.go | 157 +++++++++++++++------- 3 files changed, 334 insertions(+), 54 deletions(-) diff --git a/mock-server/app/http.php b/mock-server/app/http.php index fe2b76ecb5..ba725d7311 100644 --- a/mock-server/app/http.php +++ b/mock-server/app/http.php @@ -1056,15 +1056,21 @@ * Messaging providers and project platforms, for `appwrite init apns`. Kept per project in a file, * since the workers share no memory, so a second run sees what the first created. */ -function apnsMockState(Request $request, callable $change): array +function apnsMockState(Request $request, callable $change): mixed { - $project = \preg_replace('/[^A-Za-z0-9_-]/', '', (string) $request->getHeader('x-appwrite-project', 'default')); - $handle = \fopen(APP_STORAGE_CACHE . '/apns-' . $project . '.json', 'c+'); + $project = (string) $request->getHeader('x-appwrite-project', 'default'); + + return mockState('apns-' . $project, ['providers' => [], 'platforms' => []], $change); +} + +function mockState(string $name, array $initial, callable $change): mixed +{ + $handle = \fopen(APP_STORAGE_CACHE . '/' . \preg_replace('/[^A-Za-z0-9_-]/', '', $name) . '.json', 'c+'); if ($handle === false) { - throw new Exception(Exception::GENERAL_MOCK, 'Failed to open the APNs mock state', 500); + throw new Exception(Exception::GENERAL_MOCK, 'Failed to open the mock state', 500); } \flock($handle, LOCK_EX); - $state = \json_decode((string) \stream_get_contents($handle), true) ?: ['providers' => [], 'platforms' => []]; + $state = \json_decode((string) \stream_get_contents($handle), true) ?: $initial; $result = $change($state); \ftruncate($handle, 0); \rewind($handle); @@ -1172,6 +1178,220 @@ function apnsMockProvider(string $id, array $params): array $response->setStatusCode(Response::STATUS_CODE_CREATED)->json($platform); }); +/** + * Apple's sign-in, App Store Connect and Developer portal, for `appwrite init apns` with + * APPWRITE_APPLE_HOST set to /apple. Any password signs in, without two-factor, and the session + * cookie carries the Apple ID. Each Apple ID has one team with room for two keys, and its own App + * IDs; an App ID ending in .nopush already exists with Push Notifications off. + */ +const APPLE_MOCK_TEAM = 'E2ETEAM123'; + +function appleMockAccount(Request $request): string +{ + return \rawurldecode((string) $request->getCookie('myacinfo', '')); +} + +function appleMockState(string $account, callable $change): mixed +{ + return mockState('apple-' . \md5($account), ['keys' => [], 'appIds' => []], $change); +} + +function appleMockAppId(array $appId): array +{ + return [ + 'type' => 'bundleIds', + 'id' => $appId['id'], + 'attributes' => ['identifier' => $appId['identifier'], 'seedId' => APPLE_MOCK_TEAM], + 'relationships' => ['bundleIdCapabilities' => ['data' => $appId['push'] ? [['type' => 'bundleIdCapabilities', 'id' => $appId['id'] . '_PUSH']] : []]], + ]; +} + +function appleMockPortal(Request $request, UtopiaSwooleResponse $response, callable $handle): void +{ + $account = appleMockAccount($request); + if ($account === '') { + $response->setStatusCode(401)->json(['resultCode' => 1100, 'resultString' => 'Your session has expired']); + return; + } + $response->json(appleMockState($account, $handle) + ['resultCode' => 0]); +} + +App::get('/apple/olympus/v1/app/config') + ->inject('response') + ->action(function (UtopiaSwooleResponse $response) { + $response->json(['authServiceKey' => 'e2e-widget-key']); + }); + +App::get('/apple/olympus/v1/session') + ->inject('request') + ->inject('response') + ->action(function (Request $request, UtopiaSwooleResponse $response) { + $account = appleMockAccount($request); + if ($account === '') { + $response->setStatusCode(401)->json([]); + return; + } + $response->json(['user' => ['emailAddress' => $account], 'provider' => ['providerId' => 1, 'name' => 'E2E Team']]); + }); + +App::post('/apple/appleauth/auth/signin/init') + ->inject('response') + ->action(function (UtopiaSwooleResponse $response) { + $response->json([ + 'iteration' => 1000, + 'salt' => \base64_encode(\random_bytes(16)), + 'protocol' => 's2k', + 'b' => \base64_encode(\random_bytes(256)), + 'c' => 'e2e', + ]); + }); + +App::get('/apple/appleauth/auth/signin') + ->inject('response') + ->action(function (UtopiaSwooleResponse $response) { + $response->noContent(); + }); + +App::post('/apple/appleauth/auth/signin/complete') + ->inject('request') + ->inject('response') + ->action(function (Request $request, UtopiaSwooleResponse $response) { + $account = (string) (\json_decode($request->getRawPayload(), true)['accountName'] ?? ''); + if ($account === '') { + $response->setStatusCode(401)->json([]); + return; + } + $response + ->addCookie('myacinfo', \rawurlencode($account), \time() + 3600, '/') + ->json([]); + }); + +App::post('/apple/services-account/QH65B2/account/listTeams.action') + ->inject('request') + ->inject('response') + ->action(function (Request $request, UtopiaSwooleResponse $response) { + appleMockPortal($request, $response, fn () => [ + 'teams' => [['teamId' => APPLE_MOCK_TEAM, 'name' => 'E2E Team', 'type' => 'Company/Organization']], + ]); + }); + +App::post('/apple/services-account/QH65B2/account/auth/key/list') + ->inject('request') + ->inject('response') + ->action(function (Request $request, UtopiaSwooleResponse $response) { + $response->addHeader('csrf', 'e2e-csrf')->addHeader('csrf_ts', (string) \time()); + appleMockPortal($request, $response, fn (array &$state) => [ + 'keys' => \array_map(fn (array $key) => ['keyId' => $key['id'], 'keyName' => $key['name'], 'canRevoke' => true], $state['keys']), + ]); + }); + +App::post('/apple/services-account/QH65B2/account/auth/key/v2/create') + ->inject('request') + ->inject('response') + ->action(function (Request $request, UtopiaSwooleResponse $response) { + if ($request->getHeader('csrf', '') === '') { + $response->setStatusCode(403)->json([]); + return; + } + $name = (string) (\json_decode($request->getRawPayload(), true)['name'] ?? ''); + appleMockPortal($request, $response, function (array &$state) use ($name) { + if (\count($state['keys']) >= 2) { + return ['resultCode' => 1, 'resultString' => 'There is a problem with the request entity', 'userString' => 'You have already reached the maximum allowed number of Keys for this service.']; + } + $key = \openssl_pkey_new(['curve_name' => 'prime256v1', 'private_key_type' => OPENSSL_KEYTYPE_EC]); + \openssl_pkey_export($key, $pem); + $id = \strtoupper(\substr(\bin2hex(\random_bytes(5)), 0, 10)); + $state['keys'][] = ['id' => $id, 'name' => $name, 'p8' => $pem, 'downloaded' => false]; + return ['keys' => [['keyId' => $id, 'keyName' => $name, 'canRevoke' => true]]]; + }); + }); + +App::get('/apple/services-account/QH65B2/account/auth/key/download') + ->inject('request') + ->inject('response') + ->action(function (Request $request, UtopiaSwooleResponse $response) { + $account = appleMockAccount($request); + $keyId = (string) $request->getParam('keyId', ''); + $p8 = $account === '' ? null : appleMockState($account, function (array &$state) use ($keyId) { + foreach ($state['keys'] as $index => $key) { + if ($key['id'] === $keyId && !$key['downloaded']) { + $state['keys'][$index]['downloaded'] = true; + return $key['p8']; + } + } + return null; + }); + if ($p8 === null) { + $response->json(['resultCode' => 1, 'resultString' => 'The key cannot be downloaded']); + return; + } + $response->setContentType('application/octet-stream')->send($p8); + }); + +App::post('/apple/services-account/v1/bundleIds') + ->inject('request') + ->inject('response') + ->action(function (Request $request, UtopiaSwooleResponse $response) { + $account = appleMockAccount($request); + if ($account === '') { + $response->setStatusCode(401)->json([]); + return; + } + $body = \json_decode($request->getRawPayload(), true) ?: []; + if ($request->getHeader('x-http-method-override', '') === 'GET') { + \parse_str((string) ($body['urlEncodedQueryParams'] ?? ''), $query); + $identifier = (string) ($query['filter']['identifier'] ?? ''); + $appIds = appleMockState($account, function (array &$state) use ($identifier) { + if (\str_ends_with($identifier, '.nopush') && !isset($state['appIds'][$identifier])) { + $state['appIds'][$identifier] = ['id' => 'APPID' . \count($state['appIds']), 'identifier' => $identifier, 'push' => false]; + } + return \array_values(\array_filter($state['appIds'], fn (array $appId) => \str_starts_with($appId['identifier'], $identifier))); + }); + $response->json([ + 'data' => \array_map(appleMockAppId(...), $appIds), + 'included' => \array_values(\array_map(fn (array $appId) => [ + 'type' => 'bundleIdCapabilities', + 'id' => $appId['id'] . '_PUSH', + 'attributes' => ['capabilityType' => 'PUSH_NOTIFICATIONS', 'enabled' => true], + ], \array_filter($appIds, fn (array $appId) => $appId['push']))), + ]); + return; + } + $identifier = (string) ($body['data']['attributes']['identifier'] ?? ''); + $appId = appleMockState($account, function (array &$state) use ($identifier) { + return $state['appIds'][$identifier] = ['id' => 'APPID' . \count($state['appIds']), 'identifier' => $identifier, 'push' => false]; + }); + $response->setStatusCode(Response::STATUS_CODE_CREATED)->json(['data' => appleMockAppId($appId)]); + }); + +App::patch('/apple/services-account/v1/bundleIds/:id') + ->param('id', '', new Text(36), 'App ID.') + ->inject('request') + ->inject('response') + ->action(function (string $id, Request $request, UtopiaSwooleResponse $response) { + $account = appleMockAccount($request); + if ($account === '') { + $response->setStatusCode(401)->json([]); + return; + } + $body = \json_decode($request->getRawPayload(), true) ?: []; + $push = ($body['data']['relationships']['bundleIdCapabilities']['data'][0]['relationships']['capability']['data']['id'] ?? '') === 'PUSH_NOTIFICATIONS'; + $appId = appleMockState($account, function (array &$state) use ($id, $push) { + foreach ($state['appIds'] as $identifier => $appId) { + if ($appId['id'] === $id) { + $state['appIds'][$identifier]['push'] = $appId['push'] || $push; + return $state['appIds'][$identifier]; + } + } + return null; + }); + if ($appId === null) { + $response->setStatusCode(404)->json(['errors' => [['status' => '404', 'detail' => 'No App ID ' . $id]]]); + return; + } + $response->json(['data' => appleMockAppId($appId)]); + }); + App::shutdown() ->groups(['mock']) ->inject('utopia') diff --git a/tests/e2e/CLIGo126Test.php b/tests/e2e/CLIGo126Test.php index c14ac81d3a..398211f341 100644 --- a/tests/e2e/CLIGo126Test.php +++ b/tests/e2e/CLIGo126Test.php @@ -50,6 +50,7 @@ final class CLIGo126Test extends Base ...Base::CLI_HEADERS_RESPONSES, 'CLI_TYPES_DEPENDENCIES:passed', 'CLI_INIT_APNS:passed', + 'CLI_INIT_APNS_APPLE:passed', 'CLI_CONFORMANCE:passed', ]; diff --git a/tests/e2e/languages/cli/main.go b/tests/e2e/languages/cli/main.go index 0d781a551b..e98ca79f54 100644 --- a/tests/e2e/languages/cli/main.go +++ b/tests/e2e/languages/cli/main.go @@ -101,6 +101,8 @@ func main() { fmt.Println("CLI_TYPES_DEPENDENCIES:passed") harness.initApns() fmt.Println("CLI_INIT_APNS:passed") + harness.initApnsApple() + fmt.Println("CLI_INIT_APNS_APPLE:passed") fmt.Println("CLI_CONFORMANCE:passed") } @@ -301,20 +303,8 @@ func (h *harness) value(args ...string) string { // platform on the first run, nothing new on a rerun, and nothing written for bad input. The App ID // check needs an Apple sign-in, and without a terminal it is skipped. func (h *harness) initApns() { - dir, err := os.MkdirTemp("", "appwrite-cli-apns-") - if err != nil { - fail(err) - } - defer os.RemoveAll(dir) - - write := func(name, contents string) { - if err := os.WriteFile(filepath.Join(dir, name), []byte(contents), 0o600); err != nil { - fail(err) - } - } - // A project of its own each run: the mock keeps providers and platforms across runs. - project := "apns-e2e-" + filepath.Base(dir)[len("appwrite-cli-apns-"):] - write("appwrite.config.json", `{"projectId":"`+project+`","endpoint":"http://mockapi/v1"}`) + project := h.apnsProject() + defer os.RemoveAll(project.dir) key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) if err != nil { @@ -324,37 +314,10 @@ func (h *harness) initApns() { if err != nil { fail(err) } - write("AuthKey_ABCDE12345.p8", string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}))) - - absolute, err := filepath.Abs(binary) - if err != nil { - fail(err) - } - - run := func(wantSuccess bool, args ...string) string { - command := h.command(append([]string{"init", "apns"}, args...)...) - command.Dir = dir - command.Path = absolute - var combined strings.Builder - command.Stdout = &combined - command.Stderr = &combined - err := command.Run() - if (err == nil) != wantSuccess { - fail(fmt.Errorf("`init apns %s` exit = %v, want success %v:\n%s", strings.Join(args, " "), err, wantSuccess, combined.String())) - } - - return combined.String() - } - expect := func(output string, want ...string) { - for _, part := range want { - if !strings.Contains(output, part) { - fail(fmt.Errorf("init apns output is missing %q:\n%s", part, output)) - } - } - } + project.write("AuthKey_ABCDE12345.p8", string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}))) - first := run(true, "--key-path", "AuthKey_ABCDE12345.p8", "--team-id", "ABCDE12345", "--bundle-id", "io.appwrite.e2e") - expect(first, + first := project.run(true, "--key-path", "AuthKey_ABCDE12345.p8", "--team-id", "ABCDE12345", "--bundle-id", "io.appwrite.e2e") + expectOutput(first, "Created provider APNs (io.appwrite.e2e)", "Created provider APNs sandbox (io.appwrite.e2e)", "APNs is set up for io.appwrite.e2e", @@ -364,15 +327,111 @@ func (h *harness) initApns() { "AppwriteNotificationService", ) - again := run(true, "--bundle-id", "io.appwrite.e2e") - expect(again, "APNs is already set up for io.appwrite.e2e") + again := project.run(true, "--bundle-id", "io.appwrite.e2e") + expectOutput(again, "APNs is already set up for io.appwrite.e2e") if strings.Contains(again, "Added the Apple platform") || strings.Contains(again, "Created provider") { fail(fmt.Errorf("a rerun changed the project:\n%s", again)) } - expect(run(false, "--bundle-id", "not a bundle id!", "--key-path", "AuthKey_ABCDE12345.p8", "--team-id", "ABCDE12345"), "is not a bundle ID") - expect(run(false, "--bundle-id", "io.appwrite.e2e", "--provider", "nope"), `unknown APNs key setup "nope"`) - expect(run(false, "--bundle-id", "io.appwrite.e2e", "--environment", "staging"), "--environment must be production, sandbox or all") + expectOutput(project.run(false, "--bundle-id", "not a bundle id!", "--key-path", "AuthKey_ABCDE12345.p8", "--team-id", "ABCDE12345"), "is not a bundle ID") + expectOutput(project.run(false, "--bundle-id", "io.appwrite.e2e", "--provider", "nope"), `unknown APNs key setup "nope"`) + expectOutput(project.run(false, "--bundle-id", "io.appwrite.e2e", "--environment", "staging"), "--environment must be production, sandbox or all") +} + +// initApnsApple signs in to the mock of Apple with an Apple ID and password, creates the key and +// registers the App ID, then reuses the saved sign-in: to check the App ID on a rerun, to turn on +// Push Notifications for an existing App ID, and to create keys until the team has no free slot. +func (h *harness) initApnsApple() { + project := h.apnsProject() + defer os.RemoveAll(project.dir) + + appleID := project.name + "@example.com" + project.env = []string{"APPWRITE_APPLE_HOST=http://mockapi/apple"} + first := project.runWith([]string{"APPWRITE_APPLE_ID=" + appleID, "APPWRITE_APPLE_PASSWORD=e2e-password"}, + true, "--create-key", "--register-app-id", "--bundle-id", "io.appwrite.e2e.apple") + expectOutput(first, + "Using the Apple ID from APPWRITE_APPLE_ID", + "Using Apple team E2E Team (E2ETEAM123)", + "Created provider APNs (io.appwrite.e2e.apple)", + "Created provider APNs sandbox (io.appwrite.e2e.apple)", + "Registered io.appwrite.e2e.apple as an App ID with Push Notifications on team E2ETEAM123", + ) + keys, err := filepath.Glob(filepath.Join(project.dir, "AuthKey_*.p8")) + if err != nil || len(keys) != 1 { + fail(fmt.Errorf("want the created key saved in the project folder, found %v:\n%s", keys, first)) + } + + expectOutput(project.run(true, "--bundle-id", "io.appwrite.e2e.apple"), + "APNs is already set up for io.appwrite.e2e.apple", + "Using the saved Apple sign-in for "+appleID, + "io.appwrite.e2e.apple is registered as an App ID with Push Notifications", + ) + + expectOutput(project.run(true, "--register-app-id", "--bundle-id", "io.appwrite.e2e.nopush", "--key-path", filepath.Base(keys[0]), "--team-id", "E2ETEAM123"), + "Using the saved Apple sign-in for "+appleID, + "Turned on Push Notifications for the App ID io.appwrite.e2e.nopush", + ) + + project.run(true, "--create-key", "--bundle-id", "io.appwrite.e2e.second") + expectOutput(project.run(false, "--create-key", "--bundle-id", "io.appwrite.e2e.third"), "this team has no free slot") +} + +type apnsProject struct { + h *harness + dir string + name string + env []string +} + +// apnsProject is a project folder with a project of its own: the mock keeps state across runs. +func (h *harness) apnsProject() *apnsProject { + dir, err := os.MkdirTemp("", "appwrite-cli-apns-") + if err != nil { + fail(err) + } + project := &apnsProject{h: h, dir: dir, name: "apns-e2e-" + filepath.Base(dir)[len("appwrite-cli-apns-"):]} + project.write("appwrite.config.json", `{"projectId":"`+project.name+`","endpoint":"http://mockapi/v1"}`) + + return project +} + +func (p *apnsProject) write(name, contents string) { + if err := os.WriteFile(filepath.Join(p.dir, name), []byte(contents), 0o600); err != nil { + fail(err) + } +} + +func (p *apnsProject) run(wantSuccess bool, args ...string) string { + return p.runWith(nil, wantSuccess, args...) +} + +// runWith runs `init apns` in the project folder with extra environment variables. +func (p *apnsProject) runWith(env []string, wantSuccess bool, args ...string) string { + absolute, err := filepath.Abs(binary) + if err != nil { + fail(err) + } + command := p.h.command(append([]string{"init", "apns"}, args...)...) + command.Dir = p.dir + command.Path = absolute + command.Env = append(append(command.Env, p.env...), env...) + var combined strings.Builder + command.Stdout = &combined + command.Stderr = &combined + err = command.Run() + if (err == nil) != wantSuccess { + fail(fmt.Errorf("`init apns %s` exit = %v, want success %v:\n%s", strings.Join(args, " "), err, wantSuccess, combined.String())) + } + + return combined.String() +} + +func expectOutput(output string, want ...string) { + for _, part := range want { + if !strings.Contains(output, part) { + fail(fmt.Errorf("init apns output is missing %q:\n%s", part, output)) + } + } } func fail(err error) { From cf97d070b9cd97e3f84e3c040b80dfb642106550 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Wed, 14 Oct 2026 16:49:51 +0530 Subject: [PATCH 8/9] fix(cli): keep the Apple host override out of release builds APPWRITE_APPLE_HOST could send an Apple ID and password to any server from the shipped CLI. It now lives in a file built only with the e2e tag, which the CLI e2e suite passes; release builds always talk to Apple. --- src/SDK/Language/CLI.php | 5 +++++ templates/cli/internal/cmd/initpush.go | 11 ++++++----- templates/cli/internal/cmd/initpush_e2e.go | 20 ++++++++++++++++++++ tests/e2e/CLIGo126Test.php | 5 +++-- 4 files changed, 34 insertions(+), 7 deletions(-) create mode 100644 templates/cli/internal/cmd/initpush_e2e.go diff --git a/src/SDK/Language/CLI.php b/src/SDK/Language/CLI.php index 35ab5e1753..ff987d57e0 100644 --- a/src/SDK/Language/CLI.php +++ b/src/SDK/Language/CLI.php @@ -1136,6 +1136,11 @@ public function getFiles(): array 'destination' => 'internal/cmd/initpushdetect.go', 'template' => 'cli/internal/cmd/initpushdetect.go', ], + [ + 'scope' => 'default', + 'destination' => 'internal/cmd/initpush_e2e.go', + 'template' => 'cli/internal/cmd/initpush_e2e.go', + ], [ 'scope' => 'default', 'destination' => 'internal/cmd/initpush_test.go', diff --git a/templates/cli/internal/cmd/initpush.go b/templates/cli/internal/cmd/initpush.go index 19a65c4101..ff37018186 100644 --- a/templates/cli/internal/cmd/initpush.go +++ b/templates/cli/internal/cmd/initpush.go @@ -455,17 +455,18 @@ const ( // The Apple ID and password, when set, answer the sign-in questions. appleIDVariable = "APPWRITE_APPLE_ID" applePasswordVariable = "APPWRITE_APPLE_PASSWORD" - // appleHostVariable points Apple's sign-in and Developer portal at one - // other server, a mock of Apple in the e2e tests. - appleHostVariable = "APPWRITE_APPLE_HOST" ) +// e2eAppleHosts is set only in builds with the e2e tag, which point Apple's +// sign-in and Developer portal at the mock API. +var e2eAppleHosts func() appwrite.Hosts + // apnsAdapters lists the APNs key setups this build offers. Each lives in its // own package under internal/apns and is added here. func apnsAdapters() *apns.Registry { adapter := appwrite.New() - if host := os.Getenv(appleHostVariable); host != "" { - adapter.Hosts = appwrite.Hosts{Idmsa: host, AppStoreConnect: host, Portal: host} + if e2eAppleHosts != nil { + adapter.Hosts = e2eAppleHosts() } return apns.NewRegistry(adapter, expo.New(), fastlane.New()) diff --git a/templates/cli/internal/cmd/initpush_e2e.go b/templates/cli/internal/cmd/initpush_e2e.go new file mode 100644 index 0000000000..c116e86270 --- /dev/null +++ b/templates/cli/internal/cmd/initpush_e2e.go @@ -0,0 +1,20 @@ +//go:build e2e + +package cmd + +import ( + "os" + + "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/apns/appwrite" +) + +func init() { + e2eAppleHosts = func() appwrite.Hosts { + host := os.Getenv("APPWRITE_APPLE_HOST") + if host == "" { + return appwrite.Hosts{} + } + + return appwrite.Hosts{Idmsa: host, AppStoreConnect: host, Portal: host} + } +} diff --git a/tests/e2e/CLIGo126Test.php b/tests/e2e/CLIGo126Test.php index 398211f341..e88aecc667 100644 --- a/tests/e2e/CLIGo126Test.php +++ b/tests/e2e/CLIGo126Test.php @@ -29,8 +29,9 @@ final class CLIGo126Test extends Base 'docker run --rm -v $(pwd):/app -w /app/tests/e2e/sdks/cli golang:1.26 go mod tidy', // -buildvcs=false: the tree is generated inside this repository, and in // CI the checkout and the container disagree on ownership, so the git - // stamp fails with "dubious ownership". Linux only. - 'docker run --rm -v $(pwd):/app -w /app/tests/e2e/sdks/cli golang:1.26 go build -buildvcs=false -o appwrite .', + // stamp fails with "dubious ownership". Linux only. The e2e tag lets + // APPWRITE_APPLE_HOST point the Apple sign-in at the mock API. + 'docker run --rm -v $(pwd):/app -w /app/tests/e2e/sdks/cli golang:1.26 go build -buildvcs=false -tags e2e -o appwrite .', 'docker run --rm -v $(pwd):/app -w /app/tests/e2e/sdks/cli golang:1.26 go test ./internal/...', 'mkdir -p tests/e2e/sdks/cli/conformance', 'cp tests/e2e/languages/cli/main.go tests/e2e/sdks/cli/conformance/main.go', From 869867edb5ba2a8db25eae6c51e55f1183a06d73 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Wed, 14 Oct 2026 17:02:24 +0530 Subject: [PATCH 9/9] test(cli): copy the Apple host override in only for the e2e build The override is no longer generated into the SDK. Like the conformance harness, it lives under tests/e2e/languages/cli and CLIGo126Test copies it into the generated CLI before building, so released SDKs never contain it. --- src/SDK/Language/CLI.php | 5 ----- templates/cli/internal/cmd/initpush.go | 4 ++-- tests/e2e/CLIGo126Test.php | 7 ++++--- .../e2e/languages/cli/applehosts.go | 4 +--- 4 files changed, 7 insertions(+), 13 deletions(-) rename templates/cli/internal/cmd/initpush_e2e.go => tests/e2e/languages/cli/applehosts.go (70%) diff --git a/src/SDK/Language/CLI.php b/src/SDK/Language/CLI.php index ff987d57e0..35ab5e1753 100644 --- a/src/SDK/Language/CLI.php +++ b/src/SDK/Language/CLI.php @@ -1136,11 +1136,6 @@ public function getFiles(): array 'destination' => 'internal/cmd/initpushdetect.go', 'template' => 'cli/internal/cmd/initpushdetect.go', ], - [ - 'scope' => 'default', - 'destination' => 'internal/cmd/initpush_e2e.go', - 'template' => 'cli/internal/cmd/initpush_e2e.go', - ], [ 'scope' => 'default', 'destination' => 'internal/cmd/initpush_test.go', diff --git a/templates/cli/internal/cmd/initpush.go b/templates/cli/internal/cmd/initpush.go index ff37018186..6424e17be7 100644 --- a/templates/cli/internal/cmd/initpush.go +++ b/templates/cli/internal/cmd/initpush.go @@ -457,8 +457,8 @@ const ( applePasswordVariable = "APPWRITE_APPLE_PASSWORD" ) -// e2eAppleHosts is set only in builds with the e2e tag, which point Apple's -// sign-in and Developer portal at the mock API. +// e2eAppleHosts is set only in the CLI e2e build, which points Apple's sign-in +// and Developer portal at the mock API. var e2eAppleHosts func() appwrite.Hosts // apnsAdapters lists the APNs key setups this build offers. Each lives in its diff --git a/tests/e2e/CLIGo126Test.php b/tests/e2e/CLIGo126Test.php index e88aecc667..3dec0b451f 100644 --- a/tests/e2e/CLIGo126Test.php +++ b/tests/e2e/CLIGo126Test.php @@ -27,11 +27,12 @@ final class CLIGo126Test extends Base #[Override] protected array $build = [ 'docker run --rm -v $(pwd):/app -w /app/tests/e2e/sdks/cli golang:1.26 go mod tidy', + // Only this build gets applehosts.go, which points the Apple sign-in at the mock API. + 'cp tests/e2e/languages/cli/applehosts.go tests/e2e/sdks/cli/internal/cmd/applehosts.go', // -buildvcs=false: the tree is generated inside this repository, and in // CI the checkout and the container disagree on ownership, so the git - // stamp fails with "dubious ownership". Linux only. The e2e tag lets - // APPWRITE_APPLE_HOST point the Apple sign-in at the mock API. - 'docker run --rm -v $(pwd):/app -w /app/tests/e2e/sdks/cli golang:1.26 go build -buildvcs=false -tags e2e -o appwrite .', + // stamp fails with "dubious ownership". Linux only. + 'docker run --rm -v $(pwd):/app -w /app/tests/e2e/sdks/cli golang:1.26 go build -buildvcs=false -o appwrite .', 'docker run --rm -v $(pwd):/app -w /app/tests/e2e/sdks/cli golang:1.26 go test ./internal/...', 'mkdir -p tests/e2e/sdks/cli/conformance', 'cp tests/e2e/languages/cli/main.go tests/e2e/sdks/cli/conformance/main.go', diff --git a/templates/cli/internal/cmd/initpush_e2e.go b/tests/e2e/languages/cli/applehosts.go similarity index 70% rename from templates/cli/internal/cmd/initpush_e2e.go rename to tests/e2e/languages/cli/applehosts.go index c116e86270..3d42165497 100644 --- a/templates/cli/internal/cmd/initpush_e2e.go +++ b/tests/e2e/languages/cli/applehosts.go @@ -1,11 +1,9 @@ -//go:build e2e - package cmd import ( "os" - "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/apns/appwrite" + "github.com/repoowner/reponame/internal/apns/appwrite" ) func init() {