diff --git a/src/SDK/Language/CLI.php b/src/SDK/Language/CLI.php index 88c835a47b..adaeecb6ac 100644 --- a/src/SDK/Language/CLI.php +++ b/src/SDK/Language/CLI.php @@ -1171,6 +1171,11 @@ public function getFiles(): array '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', diff --git a/templates/cli/internal/apns/apns.go b/templates/cli/internal/apns/apns.go index 34f6702a62..5b24baf5ea 100644 --- a/templates/cli/internal/apns/apns.go +++ b/templates/cli/internal/apns/apns.go @@ -48,6 +48,9 @@ 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. @@ -73,6 +76,9 @@ var ( 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. @@ -99,12 +105,15 @@ type Request struct { // Reset signs in to Apple again instead of reusing a saved sign-in, and // the new one replaces it. Reset bool - Asker Asker + // 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 one way. +// 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 @@ -113,6 +122,101 @@ type Adapter interface { // 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 ( diff --git a/templates/cli/internal/apns/apns_test.go b/templates/cli/internal/apns/apns_test.go index dc334abeb9..6cf18df473 100644 --- a/templates/cli/internal/apns/apns_test.go +++ b/templates/cli/internal/apns/apns_test.go @@ -96,6 +96,10 @@ 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") { @@ -113,3 +117,85 @@ func TestRegistry(t *testing.T) { 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 index 5f92d12ea9..763c166f2c 100644 --- a/templates/cli/internal/apns/apnstest/apnstest.go +++ b/templates/cli/internal/apns/apnstest/apnstest.go @@ -22,6 +22,17 @@ type Adapter struct { 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". @@ -44,7 +55,36 @@ func (a *Adapter) CreateKey(_ context.Context, request apns.Request) (apns.Key, team = a.TeamID } - return apns.Key{KeyID: a.KeyID, TeamID: team, P8: P8()}, nil + 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. diff --git a/templates/cli/internal/apns/appwrite/adapter.go b/templates/cli/internal/apns/appwrite/adapter.go index 651f515fe1..51eaef7975 100644 --- a/templates/cli/internal/apns/appwrite/adapter.go +++ b/templates/cli/internal/apns/appwrite/adapter.go @@ -72,7 +72,56 @@ func (a *Adapter) CreateKey(ctx context.Context, request apns.Request) (apns.Key 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}, nil + 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 { @@ -136,6 +185,7 @@ func (a *Adapter) connect(ctx context.Context, request apns.Request) (*portalCli 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 } @@ -144,6 +194,9 @@ func (a *Adapter) connect(ctx context.Context, request apns.Request) (*portalCli 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 { @@ -179,6 +232,7 @@ func (a *Adapter) connect(ctx context.Context, request apns.Request) (*portalCli 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 } diff --git a/templates/cli/internal/apns/appwrite/adapter_test.go b/templates/cli/internal/apns/appwrite/adapter_test.go index 8128e8be58..27225f1c3a 100644 --- a/templates/cli/internal/apns/appwrite/adapter_test.go +++ b/templates/cli/internal/apns/appwrite/adapter_test.go @@ -15,6 +15,8 @@ import ( "net/url" "os" "path/filepath" + "sort" + "strconv" "strings" "sync" "testing" @@ -58,6 +60,15 @@ type fakeApple struct { 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 { @@ -66,6 +77,7 @@ func newFakeApple(t *testing.T) *fakeApple { teams: []Team{{ID: "ABCDE12345", Name: "Example Inc", Type: "Company/Organization"}}, salt: []byte("0123456789abcdef"), created: map[string]map[string]any{}, + appIDs: map[string]*fakeAppID{}, } } @@ -241,6 +253,14 @@ func (f *fakeApple) ServeHTTP(w http.ResponseWriter, r *http.Request) { } 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) @@ -305,6 +325,86 @@ func (f *fakeApple) portal(w http.ResponseWriter, r *http.Request, path string, } } +// 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 @@ -387,7 +487,7 @@ func TestCreateKeySignsInWithTwoFactorAndReusesTheSession(t *testing.T) { if err != nil { t.Fatal(err) } - if key.KeyID != "NEWKEY1234" || key.TeamID != "ABCDE12345" || key.P8 != testP8 { + if key.KeyID != "NEWKEY1234" || key.TeamID != "ABCDE12345" || key.P8 != testP8 || key.AppleID != testAppleID { t.Errorf("key = %+v", key) } created := fake.created["NEWKEY1234"] @@ -692,4 +792,123 @@ func TestResetSignsInAgain(t *testing.T) { 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 index a912188c89..dbd974f07a 100644 --- a/templates/cli/internal/apns/appwrite/auth.go +++ b/templates/cli/internal/apns/appwrite/auth.go @@ -47,6 +47,9 @@ type portalClient struct { 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 diff --git a/templates/cli/internal/apns/expo/adapter.go b/templates/cli/internal/apns/expo/adapter.go index 7d0d3946de..909f7752e2 100644 --- a/templates/cli/internal/apns/expo/adapter.go +++ b/templates/cli/internal/apns/expo/adapter.go @@ -25,6 +25,7 @@ import ( "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 @@ -63,6 +64,7 @@ func (a *Adapter) Name() string { type result struct { KeyID string `json:"keyId"` + AppleID string `json:"appleId"` TeamID string `json:"teamId"` P8 string `json:"p8"` Error string `json:"error"` @@ -72,12 +74,86 @@ type result struct { 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) {} @@ -85,10 +161,12 @@ func (a *Adapter) CreateKey(ctx context.Context, request apns.Request) (apns.Key // 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. - if request.Reset && strings.TrimSpace(request.AppleID) == "" && request.Asker != nil { + // 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 apns.Key{}, err + return nil, err } request.AppleID = strings.TrimSpace(answer) } @@ -96,33 +174,28 @@ func (a *Adapter) CreateKey(ctx context.Context, request apns.Request) (apns.Key if dir == "" { scratch, err := os.MkdirTemp("", "appwrite-apns-expo-") if err != nil { - return apns.Key{}, err + return nil, err } defer os.RemoveAll(scratch) dir = scratch } tools, err := a.toolchain(ctx, dir, log) if err != nil { - return apns.Key{}, err + return nil, err } if err := a.install(ctx, dir, tools); err != nil { - return apns.Key{}, err - } - if request.Environment != apns.EnvironmentAll { - log("The expo setup creates keys for both APNs environments; the key also works for %s.", request.Environment) + return nil, err } scratch, err := os.MkdirTemp("", "appwrite-apns-expo-result-") if err != nil { - return apns.Key{}, err + return nil, err } defer os.RemoveAll(scratch) out := filepath.Join(scratch, "result.json") - arguments := []string{filepath.Join(dir, "helper.js"), "--name", request.Name, "--out", out} - if request.TeamID != "" { - arguments = append(arguments, "--team-id", request.TeamID) - } + arguments = append([]string{filepath.Join(dir, "helper.js")}, arguments...) + arguments = append(arguments, "--out", out) if request.Reset { arguments = append(arguments, "--reset", "true") } @@ -144,13 +217,49 @@ func (a *Adapter) CreateKey(ctx context.Context, request apns.Request) (apns.Key contents, err := os.ReadFile(out) if err != nil { if runErr != nil { - return apns.Key{}, fmt.Errorf("the @expo/apple-utils helper failed: %w", runErr) + return nil, fmt.Errorf("the @expo/apple-utils helper failed: %w", runErr) } - return apns.Key{}, fmt.Errorf("the @expo/apple-utils helper returned no result: %w", err) + return nil, fmt.Errorf("the @expo/apple-utils helper returned no result: %w", err) } - return decodeResult(contents) + 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) { @@ -167,20 +276,14 @@ func decodeResult(contents []byte) (apns.Key, error) { } return apns.Key{}, &apns.MaxKeysError{Keys: keys} - case "invalid-credentials": - return apns.Key{}, fmt.Errorf("%w: %s", apns.ErrInvalidCredentials, decoded.Message) default: - if decoded.Message == "" { - decoded.Message = decoded.Error - } - - return apns.Key{}, errors.New(decoded.Message) + 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}, nil + 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) { diff --git a/templates/cli/internal/apns/expo/adapter_test.go b/templates/cli/internal/apns/expo/adapter_test.go index edf7ff9cbc..73e7894ff2 100644 --- a/templates/cli/internal/apns/expo/adapter_test.go +++ b/templates/cli/internal/apns/expo/adapter_test.go @@ -31,8 +31,8 @@ func TestSupportedNode(t *testing.T) { } 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" { + 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) } @@ -109,8 +109,8 @@ func TestCreateKeyInstallsOnceAndRunsTheHelper(t *testing.T) { t.Fatalf("calls:\n%s", calls) } for _, line := range lines[1:] { - if !strings.Contains(line, "helper.js --name Appwrite Push --out ") || - !strings.HasSuffix(line, "--team-id ABCDE12345 EXPO_NO_KEYCHAIN=1 EXPO_APPLE_ID=dev@example.com") { + 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) } } @@ -223,3 +223,113 @@ func TestResetAsksForTheAppleIDOnce(t *testing.T) { 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 index 6b992760b2..6fc8ea96a9 100644 --- a/templates/cli/internal/apns/expo/helper/helper.js +++ b/templates/cli/internal/apns/expo/helper/helper.js @@ -1,14 +1,22 @@ 'use strict'; -// Creates a team-scoped APNs key through the Apple Developer portal with -// @expo/apple-utils, the library eas-cli uses for the same job. +// 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, Keys, InvalidUserCredentialsError } = require('@expo/apple-utils'); +const { + Auth, + BundleId, + CapabilityType, + CapabilityTypeOption, + InvalidUserCredentialsError, + Keys, +} = require('@expo/apple-utils'); function parseArguments(argv) { const values = {}; @@ -16,7 +24,9 @@ function parseArguments(argv) { values[argv[index].replace(/^--/, '')] = argv[index + 1]; } if (!values.out || !values.name) { - throw new Error('usage: helper.js [--team-id ] [--reset true] --name --out '); + 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; @@ -35,19 +45,36 @@ function isMaxKeysError(error) { ); } -async function createKey({ 'team-id': teamId, name, reset }) { +// 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({}); } - // Without a team ID, apple-utils asks which team to use when there are several. - const authState = await Auth.loginAsync(teamId ? { teamId } : {}, { autoResolveProvider: true }); + 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 }); @@ -67,11 +94,59 @@ async function createKey({ 'team-id': teamId, name, reset }) { 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 createKey(options); + result = await run(options); } catch (error) { const kind = error instanceof InvalidUserCredentialsError ? 'invalid-credentials' : 'failed'; result = { error: kind, message: error?.message ?? String(error) }; diff --git a/templates/cli/internal/apns/fastlane/adapter.go b/templates/cli/internal/apns/fastlane/adapter.go index 3e9e1f2b25..9743caea1d 100644 --- a/templates/cli/internal/apns/fastlane/adapter.go +++ b/templates/cli/internal/apns/fastlane/adapter.go @@ -9,6 +9,7 @@ package fastlane import ( + "bytes" "context" _ "embed" "encoding/json" @@ -34,7 +35,7 @@ var fastfile []byte // Temporarily Unavailable" when signing in. var minimumVersion = [3]int{2, 225, 0} -// Adapter creates APNs keys with an installed fastlane. +// 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. @@ -75,26 +76,90 @@ type result struct { 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 lane. 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. +// CreateKey runs the embedded create_apns_key lane. func (a *Adapter) CreateKey(ctx context.Context, request apns.Request) (apns.Key, error) { - runner, err := a.runner() + 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: a configured command - // is never replaced. - if errors.Is(err, errNotInstalled) && a.Fastlane == "" { + // 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 apns.Key{}, err + return nil, "", err } if runner.gemfile != "" && request.Log != nil { request.Log("Using fastlane from %s with bundle exec.", runner.gemfile) @@ -104,15 +169,15 @@ func (a *Adapter) CreateKey(ctx context.Context, request apns.Request) (apns.Key if appleID == "" { answer, err := request.Asker.Ask("Apple ID (email)", false) if err != nil { - return apns.Key{}, err + return nil, "", err } appleID = strings.TrimSpace(answer) } password := request.Password - if password == "" { + if password == "" && !request.SavedSignInOnly { answer, err := request.Asker.Ask("Password for "+appleID, true) if err != nil { - return apns.Key{}, err + return nil, "", err } password = answer } @@ -120,18 +185,18 @@ func (a *Adapter) CreateKey(ctx context.Context, request apns.Request) (apns.Key // fastlane runs the Fastfile in ./fastlane of its working directory. scratch, err := os.MkdirTemp("", "appwrite-apns-fastlane-") if err != nil { - return apns.Key{}, err + return nil, "", err } defer os.RemoveAll(scratch) if err := os.MkdirAll(filepath.Join(scratch, "fastlane"), 0o700); err != nil { - return apns.Key{}, err + return nil, "", err } if err := os.WriteFile(filepath.Join(scratch, "fastlane", "Fastfile"), fastfile, 0o600); err != nil { - return apns.Key{}, err + return nil, "", err } out := filepath.Join(scratch, "result.json") - arguments := []string{"create_apns_key", "out:" + out, "name:" + request.Name, "environment:" + string(request.Environment)} + arguments = append([]string{lane, "out:" + out}, arguments...) if request.TeamID != "" { arguments = append(arguments, "team_id:"+request.TeamID) } @@ -140,26 +205,74 @@ func (a *Adapter) CreateKey(ctx context.Context, request apns.Request) (apns.Key } command := runner.command(ctx, arguments...) command.Dir = scratch - command.Stdin, command.Stdout, command.Stderr = a.terminal() + 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_PASSWORD="+password, "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 apns.Key{}, fmt.Errorf("the fastlane lane failed: %w", runErr) + return nil, "", fmt.Errorf("the fastlane lane failed: %w", runErr) } - return apns.Key{}, fmt.Errorf("the fastlane lane returned no result: %w", err) + 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 decodeResult(contents) + 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) { @@ -176,14 +289,8 @@ func decodeResult(contents []byte) (apns.Key, error) { } return apns.Key{}, &apns.MaxKeysError{Keys: keys} - case "invalid-credentials": - return apns.Key{}, fmt.Errorf("%w: %s", apns.ErrInvalidCredentials, decoded.Message) default: - if decoded.Message == "" { - decoded.Message = decoded.Error - } - - return apns.Key{}, errors.New(decoded.Message) + return apns.Key{}, decoded.err() } if decoded.KeyID == "" || decoded.P8 == "" { return apns.Key{}, errors.New("the fastlane lane returned no key") diff --git a/templates/cli/internal/apns/fastlane/adapter_test.go b/templates/cli/internal/apns/fastlane/adapter_test.go index d781813c66..0ba41ab71d 100644 --- a/templates/cli/internal/apns/fastlane/adapter_test.go +++ b/templates/cli/internal/apns/fastlane/adapter_test.go @@ -119,7 +119,7 @@ func TestCreateKeyRunsTheLane(t *testing.T) { Environment: apns.EnvironmentSandbox, Asker: asker, }) - if err != nil || key.KeyID != "KEY1234567" || key.P8 != "pem" { + 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" { @@ -442,3 +442,101 @@ func TestCreateKeyPassesResetToTheLane(t *testing.T) { 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 index c7e60b70c2..6deaaf0d83 100644 --- a/templates/cli/internal/apns/fastlane/helper/Fastfile +++ b/templates/cli/internal/apns/fastlane/helper/Fastfile @@ -1,6 +1,7 @@ -# Creates a team-scoped APNs key for `appwrite init apns --create-key -# --provider fastlane` with spaceship, fastlane's Apple Developer portal -# client, so any fastlane installation (gem, Homebrew or bundler) can run it. +# 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 @@ -9,8 +10,16 @@ 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 - create_apns_key_with_spaceship(options) + yield rescue StandardError => error kind = error.kind_of?(Spaceship::InvalidUserCredentialsError) ? 'invalid-credentials' : 'failed' { error: kind, message: error.message } @@ -18,23 +27,38 @@ lane :create_apns_key do |options| File.open(options[:out], 'w', 0o600) { |file| file.write(JSON.generate(result)) } end -def create_apns_key_with_spaceship(options) +# 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 = ENV.fetch('FASTLANE_USER', nil) }.persistent_cookie_path + cookie = Spaceship::PortalClient.new.tap { |client| client.user = user }.persistent_cookie_path File.delete(cookie) if cookie && File.exist?(cookie) end - Spaceship::Portal.login(ENV.fetch('FASTLANE_USER', nil), ENV.fetch('FASTLANE_PASSWORD', nil)) + 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 - client = Spaceship::Portal.client + 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. @@ -59,3 +83,32 @@ def create_apns_key_with_spaceship(options) 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 diff --git a/templates/cli/internal/cmd/initpush.go b/templates/cli/internal/cmd/initpush.go index bd660acf50..bc9e767a1a 100644 --- a/templates/cli/internal/cmd/initpush.go +++ b/templates/cli/internal/cmd/initpush.go @@ -37,6 +37,7 @@ import ( 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" fcmConsoleHome = "https://console.firebase.google.com" fcmServiceAccounts = "https://console.firebase.google.com/project/%s/settings/serviceaccounts/adminsdk" fcmSetupGuide = "https://firebase.google.com/docs/android/setup#add-config-file" @@ -59,6 +60,9 @@ type apnsOptions struct { reset bool environment string setup string + // registerAppID registers the bundle ID as an App ID with Push + // Notifications, without asking. + registerAppID bool } type fcmOptions struct { @@ -86,6 +90,9 @@ type pushSetup struct { // 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) { @@ -158,6 +165,7 @@ func newInitApnsCommand() *cobra.Command { 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") @@ -385,11 +393,17 @@ func (s *pushSetup) apns(options apnsOptions, detected appleApp) error { 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", requiredValue("bundle ID")) + "Which iOS bundle ID should receive push notifications?", "--bundle-id", apns.CheckBundleID) if err != nil { return err } @@ -412,7 +426,7 @@ func (s *pushSetup) apns(options apnsOptions, detected appleApp) error { output.Hint(s.out, "Pass --force or --key-path to replace the key.") s.configureXcode(detected, bundleID) - return nil + return s.prepareAppID(provisionedTeam(production, sandbox, detected.teams(bundleID)), bundleID, options, false) } teams := detected.teams(bundleID) @@ -509,7 +523,8 @@ 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) - return nil + // 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 @@ -655,22 +670,15 @@ func (a apnsAsker) Choose(question string, options []string) (int, error) { return strconv.Atoi(value) } -// 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 - } +// 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, - Name: "Appwrite Push " + time.Now().Format("20060102150405"), - Environment: environment, - AppleID: s.env(appleIDVariable), - Password: s.env(applePasswordVariable), - Reset: options.reset, - Asker: apnsAsker{s.prompter}, + 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...) }, @@ -678,8 +686,14 @@ func (s *pushSetup) createApnsKey(teamID string, options apnsOptions, environmen 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 options.reset { + if request.Reset { output.Log(s.out, "Signing in again: the saved Apple sign-in is not used (--reset).") } if request.AppleID != "" { @@ -688,10 +702,140 @@ func (s *pushSetup) createApnsKey(teamID string, options apnsOptions, environmen 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("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. Without a terminal, pass --key-path with an existing .p8 instead of --create-key", unanswered.Message, appleIDVariable, applePasswordVariable) + 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 @@ -1381,7 +1525,7 @@ func (s *pushSetup) configureXcode(detected appleApp, bundleID string) { 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. To keep these settings, add \"remote-notification\" to expo.ios.infoPlist.UIBackgroundModes in %s.", + 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)) } } diff --git a/templates/cli/internal/cmd/initpush_test.go b/templates/cli/internal/cmd/initpush_test.go index 50a050081a..cac38945be 100644 --- a/templates/cli/internal/cmd/initpush_test.go +++ b/templates/cli/internal/cmd/initpush_test.go @@ -1558,6 +1558,37 @@ func TestInitApnsPicksTheSetupFromTheFlagThenTheEnvironment(t *testing.T) { } } +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) @@ -1649,6 +1680,10 @@ 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 @@ -1719,3 +1754,154 @@ func TestInitApnsResetSignsInAgainAndCreatesAKey(t *testing.T) { 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) + } +}