diff --git a/mock-server/app/http.php b/mock-server/app/http.php index fe2b76ecb5..ba725d7311 100644 --- a/mock-server/app/http.php +++ b/mock-server/app/http.php @@ -1056,15 +1056,21 @@ * Messaging providers and project platforms, for `appwrite init apns`. Kept per project in a file, * since the workers share no memory, so a second run sees what the first created. */ -function apnsMockState(Request $request, callable $change): array +function apnsMockState(Request $request, callable $change): mixed { - $project = \preg_replace('/[^A-Za-z0-9_-]/', '', (string) $request->getHeader('x-appwrite-project', 'default')); - $handle = \fopen(APP_STORAGE_CACHE . '/apns-' . $project . '.json', 'c+'); + $project = (string) $request->getHeader('x-appwrite-project', 'default'); + + return mockState('apns-' . $project, ['providers' => [], 'platforms' => []], $change); +} + +function mockState(string $name, array $initial, callable $change): mixed +{ + $handle = \fopen(APP_STORAGE_CACHE . '/' . \preg_replace('/[^A-Za-z0-9_-]/', '', $name) . '.json', 'c+'); if ($handle === false) { - throw new Exception(Exception::GENERAL_MOCK, 'Failed to open the APNs mock state', 500); + throw new Exception(Exception::GENERAL_MOCK, 'Failed to open the mock state', 500); } \flock($handle, LOCK_EX); - $state = \json_decode((string) \stream_get_contents($handle), true) ?: ['providers' => [], 'platforms' => []]; + $state = \json_decode((string) \stream_get_contents($handle), true) ?: $initial; $result = $change($state); \ftruncate($handle, 0); \rewind($handle); @@ -1172,6 +1178,220 @@ function apnsMockProvider(string $id, array $params): array $response->setStatusCode(Response::STATUS_CODE_CREATED)->json($platform); }); +/** + * Apple's sign-in, App Store Connect and Developer portal, for `appwrite init apns` with + * APPWRITE_APPLE_HOST set to /apple. Any password signs in, without two-factor, and the session + * cookie carries the Apple ID. Each Apple ID has one team with room for two keys, and its own App + * IDs; an App ID ending in .nopush already exists with Push Notifications off. + */ +const APPLE_MOCK_TEAM = 'E2ETEAM123'; + +function appleMockAccount(Request $request): string +{ + return \rawurldecode((string) $request->getCookie('myacinfo', '')); +} + +function appleMockState(string $account, callable $change): mixed +{ + return mockState('apple-' . \md5($account), ['keys' => [], 'appIds' => []], $change); +} + +function appleMockAppId(array $appId): array +{ + return [ + 'type' => 'bundleIds', + 'id' => $appId['id'], + 'attributes' => ['identifier' => $appId['identifier'], 'seedId' => APPLE_MOCK_TEAM], + 'relationships' => ['bundleIdCapabilities' => ['data' => $appId['push'] ? [['type' => 'bundleIdCapabilities', 'id' => $appId['id'] . '_PUSH']] : []]], + ]; +} + +function appleMockPortal(Request $request, UtopiaSwooleResponse $response, callable $handle): void +{ + $account = appleMockAccount($request); + if ($account === '') { + $response->setStatusCode(401)->json(['resultCode' => 1100, 'resultString' => 'Your session has expired']); + return; + } + $response->json(appleMockState($account, $handle) + ['resultCode' => 0]); +} + +App::get('/apple/olympus/v1/app/config') + ->inject('response') + ->action(function (UtopiaSwooleResponse $response) { + $response->json(['authServiceKey' => 'e2e-widget-key']); + }); + +App::get('/apple/olympus/v1/session') + ->inject('request') + ->inject('response') + ->action(function (Request $request, UtopiaSwooleResponse $response) { + $account = appleMockAccount($request); + if ($account === '') { + $response->setStatusCode(401)->json([]); + return; + } + $response->json(['user' => ['emailAddress' => $account], 'provider' => ['providerId' => 1, 'name' => 'E2E Team']]); + }); + +App::post('/apple/appleauth/auth/signin/init') + ->inject('response') + ->action(function (UtopiaSwooleResponse $response) { + $response->json([ + 'iteration' => 1000, + 'salt' => \base64_encode(\random_bytes(16)), + 'protocol' => 's2k', + 'b' => \base64_encode(\random_bytes(256)), + 'c' => 'e2e', + ]); + }); + +App::get('/apple/appleauth/auth/signin') + ->inject('response') + ->action(function (UtopiaSwooleResponse $response) { + $response->noContent(); + }); + +App::post('/apple/appleauth/auth/signin/complete') + ->inject('request') + ->inject('response') + ->action(function (Request $request, UtopiaSwooleResponse $response) { + $account = (string) (\json_decode($request->getRawPayload(), true)['accountName'] ?? ''); + if ($account === '') { + $response->setStatusCode(401)->json([]); + return; + } + $response + ->addCookie('myacinfo', \rawurlencode($account), \time() + 3600, '/') + ->json([]); + }); + +App::post('/apple/services-account/QH65B2/account/listTeams.action') + ->inject('request') + ->inject('response') + ->action(function (Request $request, UtopiaSwooleResponse $response) { + appleMockPortal($request, $response, fn () => [ + 'teams' => [['teamId' => APPLE_MOCK_TEAM, 'name' => 'E2E Team', 'type' => 'Company/Organization']], + ]); + }); + +App::post('/apple/services-account/QH65B2/account/auth/key/list') + ->inject('request') + ->inject('response') + ->action(function (Request $request, UtopiaSwooleResponse $response) { + $response->addHeader('csrf', 'e2e-csrf')->addHeader('csrf_ts', (string) \time()); + appleMockPortal($request, $response, fn (array &$state) => [ + 'keys' => \array_map(fn (array $key) => ['keyId' => $key['id'], 'keyName' => $key['name'], 'canRevoke' => true], $state['keys']), + ]); + }); + +App::post('/apple/services-account/QH65B2/account/auth/key/v2/create') + ->inject('request') + ->inject('response') + ->action(function (Request $request, UtopiaSwooleResponse $response) { + if ($request->getHeader('csrf', '') === '') { + $response->setStatusCode(403)->json([]); + return; + } + $name = (string) (\json_decode($request->getRawPayload(), true)['name'] ?? ''); + appleMockPortal($request, $response, function (array &$state) use ($name) { + if (\count($state['keys']) >= 2) { + return ['resultCode' => 1, 'resultString' => 'There is a problem with the request entity', 'userString' => 'You have already reached the maximum allowed number of Keys for this service.']; + } + $key = \openssl_pkey_new(['curve_name' => 'prime256v1', 'private_key_type' => OPENSSL_KEYTYPE_EC]); + \openssl_pkey_export($key, $pem); + $id = \strtoupper(\substr(\bin2hex(\random_bytes(5)), 0, 10)); + $state['keys'][] = ['id' => $id, 'name' => $name, 'p8' => $pem, 'downloaded' => false]; + return ['keys' => [['keyId' => $id, 'keyName' => $name, 'canRevoke' => true]]]; + }); + }); + +App::get('/apple/services-account/QH65B2/account/auth/key/download') + ->inject('request') + ->inject('response') + ->action(function (Request $request, UtopiaSwooleResponse $response) { + $account = appleMockAccount($request); + $keyId = (string) $request->getParam('keyId', ''); + $p8 = $account === '' ? null : appleMockState($account, function (array &$state) use ($keyId) { + foreach ($state['keys'] as $index => $key) { + if ($key['id'] === $keyId && !$key['downloaded']) { + $state['keys'][$index]['downloaded'] = true; + return $key['p8']; + } + } + return null; + }); + if ($p8 === null) { + $response->json(['resultCode' => 1, 'resultString' => 'The key cannot be downloaded']); + return; + } + $response->setContentType('application/octet-stream')->send($p8); + }); + +App::post('/apple/services-account/v1/bundleIds') + ->inject('request') + ->inject('response') + ->action(function (Request $request, UtopiaSwooleResponse $response) { + $account = appleMockAccount($request); + if ($account === '') { + $response->setStatusCode(401)->json([]); + return; + } + $body = \json_decode($request->getRawPayload(), true) ?: []; + if ($request->getHeader('x-http-method-override', '') === 'GET') { + \parse_str((string) ($body['urlEncodedQueryParams'] ?? ''), $query); + $identifier = (string) ($query['filter']['identifier'] ?? ''); + $appIds = appleMockState($account, function (array &$state) use ($identifier) { + if (\str_ends_with($identifier, '.nopush') && !isset($state['appIds'][$identifier])) { + $state['appIds'][$identifier] = ['id' => 'APPID' . \count($state['appIds']), 'identifier' => $identifier, 'push' => false]; + } + return \array_values(\array_filter($state['appIds'], fn (array $appId) => \str_starts_with($appId['identifier'], $identifier))); + }); + $response->json([ + 'data' => \array_map(appleMockAppId(...), $appIds), + 'included' => \array_values(\array_map(fn (array $appId) => [ + 'type' => 'bundleIdCapabilities', + 'id' => $appId['id'] . '_PUSH', + 'attributes' => ['capabilityType' => 'PUSH_NOTIFICATIONS', 'enabled' => true], + ], \array_filter($appIds, fn (array $appId) => $appId['push']))), + ]); + return; + } + $identifier = (string) ($body['data']['attributes']['identifier'] ?? ''); + $appId = appleMockState($account, function (array &$state) use ($identifier) { + return $state['appIds'][$identifier] = ['id' => 'APPID' . \count($state['appIds']), 'identifier' => $identifier, 'push' => false]; + }); + $response->setStatusCode(Response::STATUS_CODE_CREATED)->json(['data' => appleMockAppId($appId)]); + }); + +App::patch('/apple/services-account/v1/bundleIds/:id') + ->param('id', '', new Text(36), 'App ID.') + ->inject('request') + ->inject('response') + ->action(function (string $id, Request $request, UtopiaSwooleResponse $response) { + $account = appleMockAccount($request); + if ($account === '') { + $response->setStatusCode(401)->json([]); + return; + } + $body = \json_decode($request->getRawPayload(), true) ?: []; + $push = ($body['data']['relationships']['bundleIdCapabilities']['data'][0]['relationships']['capability']['data']['id'] ?? '') === 'PUSH_NOTIFICATIONS'; + $appId = appleMockState($account, function (array &$state) use ($id, $push) { + foreach ($state['appIds'] as $identifier => $appId) { + if ($appId['id'] === $id) { + $state['appIds'][$identifier]['push'] = $appId['push'] || $push; + return $state['appIds'][$identifier]; + } + } + return null; + }); + if ($appId === null) { + $response->setStatusCode(404)->json(['errors' => [['status' => '404', 'detail' => 'No App ID ' . $id]]]); + return; + } + $response->json(['data' => appleMockAppId($appId)]); + }); + App::shutdown() ->groups(['mock']) ->inject('utopia') diff --git a/templates/cli/internal/cmd/initpush.go b/templates/cli/internal/cmd/initpush.go index 5771290e3a..19a65c4101 100644 --- a/templates/cli/internal/cmd/initpush.go +++ b/templates/cli/internal/cmd/initpush.go @@ -455,12 +455,20 @@ const ( // The Apple ID and password, when set, answer the sign-in questions. appleIDVariable = "APPWRITE_APPLE_ID" applePasswordVariable = "APPWRITE_APPLE_PASSWORD" + // appleHostVariable points Apple's sign-in and Developer portal at one + // other server, a mock of Apple in the e2e tests. + appleHostVariable = "APPWRITE_APPLE_HOST" ) // apnsAdapters lists the APNs key setups this build offers. Each lives in its // own package under internal/apns and is added here. func apnsAdapters() *apns.Registry { - return apns.NewRegistry(appwrite.New(), expo.New(), fastlane.New()) + adapter := appwrite.New() + if host := os.Getenv(appleHostVariable); host != "" { + adapter.Hosts = appwrite.Hosts{Idmsa: host, AppStoreConnect: host, Portal: host} + } + + return apns.NewRegistry(adapter, expo.New(), fastlane.New()) } // apnsAdapter picks the setup: --provider, then APPWRITE_APNS_SETUP, then diff --git a/tests/e2e/CLIGo126Test.php b/tests/e2e/CLIGo126Test.php index c14ac81d3a..398211f341 100644 --- a/tests/e2e/CLIGo126Test.php +++ b/tests/e2e/CLIGo126Test.php @@ -50,6 +50,7 @@ final class CLIGo126Test extends Base ...Base::CLI_HEADERS_RESPONSES, 'CLI_TYPES_DEPENDENCIES:passed', 'CLI_INIT_APNS:passed', + 'CLI_INIT_APNS_APPLE:passed', 'CLI_CONFORMANCE:passed', ]; diff --git a/tests/e2e/languages/cli/main.go b/tests/e2e/languages/cli/main.go index 0d781a551b..e98ca79f54 100644 --- a/tests/e2e/languages/cli/main.go +++ b/tests/e2e/languages/cli/main.go @@ -101,6 +101,8 @@ func main() { fmt.Println("CLI_TYPES_DEPENDENCIES:passed") harness.initApns() fmt.Println("CLI_INIT_APNS:passed") + harness.initApnsApple() + fmt.Println("CLI_INIT_APNS_APPLE:passed") fmt.Println("CLI_CONFORMANCE:passed") } @@ -301,20 +303,8 @@ func (h *harness) value(args ...string) string { // platform on the first run, nothing new on a rerun, and nothing written for bad input. The App ID // check needs an Apple sign-in, and without a terminal it is skipped. func (h *harness) initApns() { - dir, err := os.MkdirTemp("", "appwrite-cli-apns-") - if err != nil { - fail(err) - } - defer os.RemoveAll(dir) - - write := func(name, contents string) { - if err := os.WriteFile(filepath.Join(dir, name), []byte(contents), 0o600); err != nil { - fail(err) - } - } - // A project of its own each run: the mock keeps providers and platforms across runs. - project := "apns-e2e-" + filepath.Base(dir)[len("appwrite-cli-apns-"):] - write("appwrite.config.json", `{"projectId":"`+project+`","endpoint":"http://mockapi/v1"}`) + project := h.apnsProject() + defer os.RemoveAll(project.dir) key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) if err != nil { @@ -324,37 +314,10 @@ func (h *harness) initApns() { if err != nil { fail(err) } - write("AuthKey_ABCDE12345.p8", string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}))) - - absolute, err := filepath.Abs(binary) - if err != nil { - fail(err) - } - - run := func(wantSuccess bool, args ...string) string { - command := h.command(append([]string{"init", "apns"}, args...)...) - command.Dir = dir - command.Path = absolute - var combined strings.Builder - command.Stdout = &combined - command.Stderr = &combined - err := command.Run() - if (err == nil) != wantSuccess { - fail(fmt.Errorf("`init apns %s` exit = %v, want success %v:\n%s", strings.Join(args, " "), err, wantSuccess, combined.String())) - } - - return combined.String() - } - expect := func(output string, want ...string) { - for _, part := range want { - if !strings.Contains(output, part) { - fail(fmt.Errorf("init apns output is missing %q:\n%s", part, output)) - } - } - } + project.write("AuthKey_ABCDE12345.p8", string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}))) - first := run(true, "--key-path", "AuthKey_ABCDE12345.p8", "--team-id", "ABCDE12345", "--bundle-id", "io.appwrite.e2e") - expect(first, + first := project.run(true, "--key-path", "AuthKey_ABCDE12345.p8", "--team-id", "ABCDE12345", "--bundle-id", "io.appwrite.e2e") + expectOutput(first, "Created provider APNs (io.appwrite.e2e)", "Created provider APNs sandbox (io.appwrite.e2e)", "APNs is set up for io.appwrite.e2e", @@ -364,15 +327,111 @@ func (h *harness) initApns() { "AppwriteNotificationService", ) - again := run(true, "--bundle-id", "io.appwrite.e2e") - expect(again, "APNs is already set up for io.appwrite.e2e") + again := project.run(true, "--bundle-id", "io.appwrite.e2e") + expectOutput(again, "APNs is already set up for io.appwrite.e2e") if strings.Contains(again, "Added the Apple platform") || strings.Contains(again, "Created provider") { fail(fmt.Errorf("a rerun changed the project:\n%s", again)) } - expect(run(false, "--bundle-id", "not a bundle id!", "--key-path", "AuthKey_ABCDE12345.p8", "--team-id", "ABCDE12345"), "is not a bundle ID") - expect(run(false, "--bundle-id", "io.appwrite.e2e", "--provider", "nope"), `unknown APNs key setup "nope"`) - expect(run(false, "--bundle-id", "io.appwrite.e2e", "--environment", "staging"), "--environment must be production, sandbox or all") + expectOutput(project.run(false, "--bundle-id", "not a bundle id!", "--key-path", "AuthKey_ABCDE12345.p8", "--team-id", "ABCDE12345"), "is not a bundle ID") + expectOutput(project.run(false, "--bundle-id", "io.appwrite.e2e", "--provider", "nope"), `unknown APNs key setup "nope"`) + expectOutput(project.run(false, "--bundle-id", "io.appwrite.e2e", "--environment", "staging"), "--environment must be production, sandbox or all") +} + +// initApnsApple signs in to the mock of Apple with an Apple ID and password, creates the key and +// registers the App ID, then reuses the saved sign-in: to check the App ID on a rerun, to turn on +// Push Notifications for an existing App ID, and to create keys until the team has no free slot. +func (h *harness) initApnsApple() { + project := h.apnsProject() + defer os.RemoveAll(project.dir) + + appleID := project.name + "@example.com" + project.env = []string{"APPWRITE_APPLE_HOST=http://mockapi/apple"} + first := project.runWith([]string{"APPWRITE_APPLE_ID=" + appleID, "APPWRITE_APPLE_PASSWORD=e2e-password"}, + true, "--create-key", "--register-app-id", "--bundle-id", "io.appwrite.e2e.apple") + expectOutput(first, + "Using the Apple ID from APPWRITE_APPLE_ID", + "Using Apple team E2E Team (E2ETEAM123)", + "Created provider APNs (io.appwrite.e2e.apple)", + "Created provider APNs sandbox (io.appwrite.e2e.apple)", + "Registered io.appwrite.e2e.apple as an App ID with Push Notifications on team E2ETEAM123", + ) + keys, err := filepath.Glob(filepath.Join(project.dir, "AuthKey_*.p8")) + if err != nil || len(keys) != 1 { + fail(fmt.Errorf("want the created key saved in the project folder, found %v:\n%s", keys, first)) + } + + expectOutput(project.run(true, "--bundle-id", "io.appwrite.e2e.apple"), + "APNs is already set up for io.appwrite.e2e.apple", + "Using the saved Apple sign-in for "+appleID, + "io.appwrite.e2e.apple is registered as an App ID with Push Notifications", + ) + + expectOutput(project.run(true, "--register-app-id", "--bundle-id", "io.appwrite.e2e.nopush", "--key-path", filepath.Base(keys[0]), "--team-id", "E2ETEAM123"), + "Using the saved Apple sign-in for "+appleID, + "Turned on Push Notifications for the App ID io.appwrite.e2e.nopush", + ) + + project.run(true, "--create-key", "--bundle-id", "io.appwrite.e2e.second") + expectOutput(project.run(false, "--create-key", "--bundle-id", "io.appwrite.e2e.third"), "this team has no free slot") +} + +type apnsProject struct { + h *harness + dir string + name string + env []string +} + +// apnsProject is a project folder with a project of its own: the mock keeps state across runs. +func (h *harness) apnsProject() *apnsProject { + dir, err := os.MkdirTemp("", "appwrite-cli-apns-") + if err != nil { + fail(err) + } + project := &apnsProject{h: h, dir: dir, name: "apns-e2e-" + filepath.Base(dir)[len("appwrite-cli-apns-"):]} + project.write("appwrite.config.json", `{"projectId":"`+project.name+`","endpoint":"http://mockapi/v1"}`) + + return project +} + +func (p *apnsProject) write(name, contents string) { + if err := os.WriteFile(filepath.Join(p.dir, name), []byte(contents), 0o600); err != nil { + fail(err) + } +} + +func (p *apnsProject) run(wantSuccess bool, args ...string) string { + return p.runWith(nil, wantSuccess, args...) +} + +// runWith runs `init apns` in the project folder with extra environment variables. +func (p *apnsProject) runWith(env []string, wantSuccess bool, args ...string) string { + absolute, err := filepath.Abs(binary) + if err != nil { + fail(err) + } + command := p.h.command(append([]string{"init", "apns"}, args...)...) + command.Dir = p.dir + command.Path = absolute + command.Env = append(append(command.Env, p.env...), env...) + var combined strings.Builder + command.Stdout = &combined + command.Stderr = &combined + err = command.Run() + if (err == nil) != wantSuccess { + fail(fmt.Errorf("`init apns %s` exit = %v, want success %v:\n%s", strings.Join(args, " "), err, wantSuccess, combined.String())) + } + + return combined.String() +} + +func expectOutput(output string, want ...string) { + for _, part := range want { + if !strings.Contains(output, part) { + fail(fmt.Errorf("init apns output is missing %q:\n%s", part, output)) + } + } } func fail(err error) {