diff --git a/CLAUDE.md b/CLAUDE.md index 5eb46ec..cbe53f4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -32,8 +32,13 @@ go install ./cmd/builder ./builder dev rn --skip-install --bundle-id # Use already installed app ./builder auth apple # Save an App Store Connect API key ./builder ios upload --wait # Upload dist/*.ipa to App Store Connect, wait for processing -./builder ios submit --testflight --group --notes # TestFlight +./builder ios submit --testflight --group --notes # TestFlight (creates the group if missing) ./builder ios submit --app-store --release after-approval # App Review +./builder asc apps|builds|groups|testers|users # App Store Connect listings (--json) +./builder asc groups create [--external] # also: groups delete, groups add-build +./builder asc testers add ... --group # also: testers remove, users invite +./builder asc testers invite ... # send/resend the TestFlight email +./builder asc builds expire --build-number N --yes # groups delete needs --yes too ``` ## Architecture @@ -135,8 +140,9 @@ cmd/builder/ # CLI entrypoint (Cobra) internal/ auth/ # GitHub OAuth device flow + keyring storage (also CI tokens, ASC API key) github/ # GitHub REST API (workflow dispatch, artifacts) - asc/ # App Store Connect API client (JWT, JSON:API, builds, uploads, TestFlight, review) - distribute/ # Upload / TestFlight / App Store flows on top of asc + asc/ # App Store Connect API client (JWT, JSON:API, apps, builds, uploads, TestFlight, + # beta groups, beta testers, team users/invitations, review) + distribute/ # Upload / TestFlight / App Store / tester flows on top of asc ipa/ # Info.plist reading from .ipa archives build/ # Build coordination (snapshot + trigger + poll + download) signing/ # CSR generation and .p12 assembly (signing without a Mac) @@ -235,6 +241,36 @@ internal/ - **Submit Order**: TestFlight is compliance → notes → `betaAppReviewSubmissions` (only for a new external group) → add groups. App Store reuses an open `reviewSubmission`, skips an item the version is already in, and rewrites ASC 409/422 with a "complete the metadata" hint. +- **Group Auto-Create**: `SubmitTestFlight` creates any `--group` name the app lacks (internal, or + external with `External`/`--external`) and marks it `GroupRef.Created`; existing groups keep + their type. `asc groups add-build` reuses it, so it inherits the beta-review step too. +- **Automatic Distribution Groups**: an internal group with `hasAccessToAllBuilds: true` gets every + build by itself, so `POST builds/{id}/relationships/betaGroups` answers 422 and the add-build path + skips it (`GroupRef.AutoBuilds`, exit 0). `asc groups create` sets it unless `--no-auto-builds`. +- **Internal Testers**: internal groups take team members only, so `distribute.AddTester` routes by + group type — external creates the tester in the group (409 → find by email → add), internal joins + the member's record or `POST userInvitations` for a stranger, who must accept first. +- **ASC Filters Are Substrings**: Apple's `filter[email]`/`filter[username]` match substrings, so + `FindBetaTester`/`FindUser` compare the address exactly; `filter[email]` goes lowercased because + ASC stores addresses that way. +- **NOT_INVITED Testers**: a team member put into an internal group stays `NOT_INVITED` with no + email until `POST betaTesterInvitations`, so `AddTester` re-reads the state after a group add and + `asc testers invite` sends it on demand (ACCEPTED/INSTALLED are left alone). +- **No Installable Build**: while no group of a tester's has a build, `betaTesterInvitations` + answers 409 `asc.CodeNoInstallableBuilds`: `InviteTester` turns it into a `noBuildError` naming + `asc groups add-build`, and `AddTester` into a plain "added" rather than a failure. +- **Group Name Matching**: `asc.MatchBetaGroup` is the only name lookup (command layer and + `findOrCreateGroup`): case-insensitive, nil when absent, and an error listing the candidates when + several groups fold to the same name, so nothing is created, deleted or linked on a guess. +- **Destructive asc Commands**: `groups delete`, `testers remove` without `--group` and + `builds expire` resolve everything first, print a "Will ..." line naming exactly what goes, and + then need `--yes`; `testers remove` looks every address up before the first deletion. +- **asc Command Layer**: `cmd/builder/asc.go` is thin cobra over `asc` and `distribute`; + `resolveApp` (`--bundle-id` → `--ipa` → `ios.bundleId` → newest `dist/*.ipa`) and `runTestFlight` + are shared with `ios submit`, and builds list with `include=preReleaseVersion,betaGroups`. +- **asc Command Tests**: `getASCClient` is a package var so tests can point it at an httptest + server, and their `run` helper resets every flag first, since cobra keeps flag values on the + shared command tree. - **Extension Points**: a future `ios release` composes `distribute.Upload` and `distribute.SubmitTestFlight`, reading `asc.Client.ListBuilds` for the latest build number; the `pkg/` wrappers do not expose `asc` yet. @@ -247,10 +283,12 @@ internal/ "project": "MyApp", "platform": "ios", "github": { "owner": "username", "repo": "my-ios-app" }, - "ios": { "path": "ios", "scheme": "" } + "ios": { "path": "ios", "scheme": "", "bundleId": "com.example.myapp" } } ``` +`ios.bundleId` is optional; the `asc` commands fall back to the newest IPA in `./dist/`. + ## Workflow Features The embedded workflow template (`internal/workflow/templates/ios-build.yml`): diff --git a/README.md b/README.md index 31d56dc..cef1072 100644 --- a/README.md +++ b/README.md @@ -239,10 +239,25 @@ builder signing setup # Upload code signing secrets to GitHub builder ios upload --wait # Upload ./dist/*.ipa to App Store Connect and wait for processing builder ios submit --testflight --group "Beta Testers" --notes "What to test" builder ios submit --app-store --release after-approval # Submit the version for App Review + +# App Store Connect management (needs builder auth apple) +builder asc apps # Apps the API key can see +builder asc builds # Builds of the newest version, with their TestFlight groups +builder asc builds expire --build-number 42 --yes +builder asc groups # TestFlight groups with tester counts +builder asc groups create Nightly # Internal group (add --external for external) +builder asc groups add-build Nightly # Newest VALID build (or --build-number) +builder asc groups delete Nightly --yes +builder asc testers --group Nightly # With each tester's state +builder asc testers add a@example.com --group Nightly --first Ann --last Lee +builder asc testers invite a@example.com # Send or resend the TestFlight email +builder asc testers remove a@example.com --group Nightly +builder asc users # Team members and whether they can test internally +builder asc users invite dev@example.com --role DEVELOPER --first Dee --last Vee ``` -Every `upload`/`submit` command takes `--json` for machine-readable output and -never prompts, so agents and CI jobs can drive them. +Every `upload`/`submit`/`asc` command takes `--json` for machine-readable output +and never prompts, so agents and CI jobs can drive them. ## Configuration @@ -454,10 +469,12 @@ builder ios submit --testflight --group "Beta Testers" --notes "New login flow" ``` This takes the newest processed build (or `--build-number N`), sets the *What -to Test* notes and adds it to the named groups (`--group` repeats). Internal -groups get the build immediately; the first external group triggers Apple's -beta review, which Builder submits for you (`--wait` follows the decision). Run -it without `--group` to see the build and the groups the app has. +to Test* notes and adds it to the named groups (`--group` repeats). A group +that does not exist yet is created — internal by default, external with +`--external`. Internal groups get the build immediately; the first external +group triggers Apple's beta review, which Builder submits for you (`--wait` +follows the decision). Run it without `--group` to see the build and the +groups the app has. ### 4. Submit to the App Store @@ -476,6 +493,53 @@ with [asc-cli](https://github.com/tddworks/asc-cli), whose production use of the `buildUploads` API also proved that the Mac-free upload path works and served as the reference for Builder's implementation. +## Managing TestFlight + +`builder asc` covers the App Store Connect housekeeping around TestFlight +without the website: apps, builds, groups, testers and team members. Every +command takes `--json` (result on stdout, progress on stderr), never prompts, +and finds the app through `--bundle-id`, then `ios.bundleId` in +`builder.json`, then the newest IPA in `./dist/`. + +```bash +builder asc builds # newest version's builds and their groups +builder asc groups create Nightly # internal group; --external for outsiders +builder asc groups add-build Nightly # same as ios submit --testflight --group +builder asc testers add a@example.com b@example.com --group Nightly +builder asc testers # every tester with their state +builder asc testers invite a@example.com # send or resend the TestFlight email +builder asc testers remove a@example.com --group Nightly +builder asc builds expire --build-number 42 --yes +``` + +Two things about internal groups: + +- **They take team members only.** `asc testers add` puts a member's tester + record into the group and invites a stranger to the App Store Connect team + first (`--role`, default `CUSTOMER_SUPPORT`, only this app visible; + `--first` and `--last` required). They must accept that email before a build + reaches them, so rerun the command afterwards. `asc users` shows the team and + who already has TestFlight access; `asc users invite` invites on its own. +- **Automatic distribution.** An internal group with "automatic distribution" + (the default of `asc groups create`, off with `--no-auto-builds`) receives + every processed build by itself and Apple refuses to add builds by hand, so + `asc groups` marks it `internal, all builds` and `ios submit --group` and + `asc groups add-build` skip it with a note instead of failing. + +`NOT_INVITED` means no email has gone out — how a team member added to an +internal group in App Store Connect shows up. `asc testers invite` sends it +(or resends while `INVITED`) and `asc testers add` does so by itself, unless +the group has no build yet: Apple refuses to invite anyone into a group with +nothing to install, so `add` reports "invite goes out once the group has a +build" and `invite` says to run `asc groups add-build` first (an external +group's build must also pass Beta App Review). + +External groups take anyone by email, reusing a tester the team already has. +`asc groups delete`, and `asc testers remove` without `--group` (which drops +the tester from TestFlight team-wide), print what goes and then need `--yes`. +Group names match case-insensitively; when two differ only by case, the +command refuses and lists both. + ## Installing the IPA Use [MobAI](https://mobai.run) to install your IPA directly on your device. It works with both signed and unsigned builds: an unsigned IPA can be re-signed on install with a free Apple ID (MobAI asks for the account). diff --git a/cmd/builder/asc.go b/cmd/builder/asc.go new file mode 100644 index 0000000..97c7976 --- /dev/null +++ b/cmd/builder/asc.go @@ -0,0 +1,846 @@ +package main + +import ( + "context" + "errors" + "fmt" + "io" + "strings" + "text/tabwriter" + "time" + + "github.com/MobAI-App/ios-builder/internal/asc" + "github.com/MobAI-App/ios-builder/internal/config" + "github.com/MobAI-App/ios-builder/internal/distribute" + "github.com/MobAI-App/ios-builder/internal/ipa" + "github.com/spf13/cobra" +) + +var ascCmd = &cobra.Command{ + Use: "asc", + Short: "App Store Connect: apps, builds, TestFlight groups and testers", + Long: `Lists and manages what App Store Connect knows about the app, from any +platform. Every command is non-interactive and takes --json. + +The app is identified by --bundle-id, else ios.bundleId in builder.json, else +the newest IPA in ./dist. Needs an App Store Connect API key: builder auth apple.`, +} + +var ascAppsCmd = &cobra.Command{ + Use: "apps", + Short: "List the team's apps", + Args: cobra.NoArgs, + RunE: runASCApps, +} + +var ascBuildsCmd = &cobra.Command{ + Use: "builds", + Short: "List the app's builds, newest first", + Long: `Lists the builds of the newest marketing version with their processing +state and TestFlight groups; --all-versions lists every version.`, + Args: cobra.NoArgs, + RunE: runASCBuilds, +} + +var ascBuildsExpireCmd = &cobra.Command{ + Use: "expire", + Short: "Expire a build so TestFlight stops offering it", + Args: cobra.NoArgs, + RunE: runASCBuildsExpire, +} + +var ascGroupsCmd = &cobra.Command{ + Use: "groups", + Short: "List the app's TestFlight groups", + Args: cobra.NoArgs, + RunE: runASCGroups, +} + +var ascGroupsCreateCmd = &cobra.Command{ + Use: "create ", + Short: "Create a TestFlight group (internal unless --external)", + Args: cobra.ExactArgs(1), + RunE: runASCGroupsCreate, +} + +var ascGroupsDeleteCmd = &cobra.Command{ + Use: "delete ", + Short: "Delete a TestFlight group (its testers stay on the team)", + Args: cobra.ExactArgs(1), + RunE: runASCGroupsDelete, +} + +var ascGroupsAddBuildCmd = &cobra.Command{ + Use: "add-build ", + Short: "Add the newest VALID build (or --build-number) to a group", + Long: `Adds a processed build to the group, creating the group when it does not +exist, exactly like builder ios submit --testflight --group. An external group +gets the build submitted for beta review first.`, + Args: cobra.ExactArgs(1), + RunE: runASCGroupsAddBuild, +} + +var ascTestersCmd = &cobra.Command{ + Use: "testers", + Short: "List the app's TestFlight testers (or one group's with --group)", + Args: cobra.NoArgs, + RunE: runASCTesters, +} + +var ascTestersAddCmd = &cobra.Command{ + Use: "add ...", + Short: "Invite testers to a TestFlight group", + Long: `External groups take anyone: each tester is created in the group, which sends +the TestFlight invitation, or added to it when the team already has them. + +Internal groups take App Store Connect team members only. A member is added +to the group; anyone else is invited to the team first (--role, default +CUSTOMER_SUPPORT, with only this app visible; --first and --last required). +They must accept that email before the build can reach them: rerun afterwards.`, + Args: cobra.MinimumNArgs(1), + RunE: runASCTestersAdd, +} + +var ascUsersCmd = &cobra.Command{ + Use: "users", + Short: "List the App Store Connect team (email, roles, TestFlight access)", + Args: cobra.NoArgs, + RunE: runASCUsers, +} + +var ascUsersInviteCmd = &cobra.Command{ + Use: "invite ", + Short: "Invite a person to the App Store Connect team", + Long: `Sends a team invitation with the given role (default CUSTOMER_SUPPORT) and +only this app visible; --all-apps makes every app visible instead.`, + Args: cobra.ExactArgs(1), + RunE: runASCUsersInvite, +} + +var ascTestersRemoveCmd = &cobra.Command{ + Use: "remove ...", + Short: "Remove testers from a group (--group) or from TestFlight entirely (--yes)", + Args: cobra.MinimumNArgs(1), + RunE: runASCTestersRemove, +} + +var ascTestersInviteCmd = &cobra.Command{ + Use: "invite ...", + Short: "Send (or resend) the TestFlight invitation email to the app's testers", + Long: `Emails the app's TestFlight invitation to testers it already has. A team +member added to an internal group in App Store Connect stays NOT_INVITED and +never hears about a build until this is run; INVITED testers get the email +again. Accepted or installed testers are left alone. --group looks the +testers up in that group only.`, + Args: cobra.MinimumNArgs(1), + RunE: runASCTestersInvite, +} + +func init() { + ascAppsCmd.Flags().Bool("json", false, "Print the result as JSON") + ascBuildsCmd.Flags().Int("limit", 20, "Newest builds to list (0 for all)") + ascBuildsCmd.Flags().Bool("all-versions", false, "List builds of every marketing version, not only the newest") + ascBuildsExpireCmd.Flags().String("build-number", "", "Build number (CFBundleVersion) to expire") + _ = ascBuildsExpireCmd.MarkFlagRequired("build-number") + ascBuildsExpireCmd.Flags().Bool("yes", false, "Confirm; expiring cannot be undone") + ascGroupsCreateCmd.Flags().Bool("external", false, "Create an external group (builds need beta review)") + ascGroupsCreateCmd.Flags().Bool("public-link", false, "Enable the public invitation link (external groups only)") + ascGroupsCreateCmd.Flags().Bool("no-auto-builds", false, "Internal group without automatic distribution: builds are added by hand") + ascGroupsDeleteCmd.Flags().Bool("yes", false, "Delete even when the group has testers") + ascGroupsAddBuildCmd.Flags().String("build-number", "", "Build number (CFBundleVersion) to add (default: newest VALID build)") + ascGroupsAddBuildCmd.Flags().Bool("no-encryption", false, "Declare the app uses no non-exempt encryption (export compliance)") + ascTestersCmd.Flags().String("group", "", "Only the testers of this group") + ascTestersAddCmd.Flags().String("group", "", "TestFlight group to invite the testers to") + _ = ascTestersAddCmd.MarkFlagRequired("group") + ascTestersAddCmd.Flags().String("first", "", "First name") + ascTestersAddCmd.Flags().String("last", "", "Last name") + ascTestersAddCmd.Flags().String("role", asc.RoleCustomerSupport, "Team role for a person an internal group needs invited to the team") + ascTestersRemoveCmd.Flags().String("group", "", "Remove from this group only") + ascTestersRemoveCmd.Flags().Bool("yes", false, "Confirm removing the testers from TestFlight entirely (without --group)") + ascTestersInviteCmd.Flags().String("group", "", "Look the testers up in this group only") + ascUsersCmd.Flags().Bool("json", false, "Print the result as JSON") + ascUsersInviteCmd.Flags().String("role", asc.RoleCustomerSupport, "Team role (ADMIN, APP_MANAGER, DEVELOPER, MARKETING, CUSTOMER_SUPPORT, ...)") + ascUsersInviteCmd.Flags().String("first", "", "First name (required)") + ascUsersInviteCmd.Flags().String("last", "", "Last name (required)") + ascUsersInviteCmd.Flags().Bool("all-apps", false, "Make every app visible, not only this one") + for _, cmd := range []*cobra.Command{ascBuildsCmd, ascBuildsExpireCmd, ascGroupsCmd, ascGroupsCreateCmd, ascGroupsDeleteCmd, ascGroupsAddBuildCmd, ascTestersCmd, ascTestersAddCmd, ascTestersRemoveCmd, ascTestersInviteCmd, ascUsersInviteCmd} { + cmd.Flags().String("bundle-id", "", "App bundle ID (default: ios.bundleId in builder.json, else the newest IPA in ./dist)") + cmd.Flags().Bool("json", false, "Print the result as JSON (progress goes to stderr)") + } + ascBuildsCmd.AddCommand(ascBuildsExpireCmd) + ascGroupsCmd.AddCommand(ascGroupsCreateCmd, ascGroupsDeleteCmd, ascGroupsAddBuildCmd) + ascTestersCmd.AddCommand(ascTestersAddCmd, ascTestersRemoveCmd, ascTestersInviteCmd) + ascUsersCmd.AddCommand(ascUsersInviteCmd) + ascCmd.AddCommand(ascAppsCmd, ascBuildsCmd, ascGroupsCmd, ascTestersCmd, ascUsersCmd) +} + +// resolveApp picks the app a command works on: --bundle-id, else --ipa when +// the command has that flag, else ios.bundleId in builder.json, else the +// newest IPA in ./dist. version is the marketing version when an IPA was read. +func resolveApp(cmd *cobra.Command) (bundleID, version string, err error) { + if id, _ := cmd.Flags().GetString("bundle-id"); id != "" { + return id, "", nil + } + path, _ := cmd.Flags().GetString("ipa") + if path == "" { + cfg, err := config.NewManager().Load() + if err != nil && !errors.Is(err, config.ErrConfigNotFound) { + return "", "", err + } + if cfg != nil && cfg.IOS.BundleID != "" { + return cfg.IOS.BundleID, "", nil + } + if path, err = ipa.Newest("dist"); err != nil { + return "", "", fmt.Errorf("cannot tell which app: pass --bundle-id, set ios.bundleId in builder.json, or build an IPA into ./dist") + } + } + info, err := ipa.ReadInfo(path) + if err != nil { + return "", "", err + } + return info.BundleID, info.Version, nil +} + +// ascSession is what every asc command working on one app starts with. +type ascSession struct { + ctx context.Context + client *asc.Client + app *asc.App + out output +} + +func openASC(cmd *cobra.Command) (*ascSession, context.CancelFunc, error) { + client, err := getASCClient() + if err != nil { + return nil, nil, err + } + bundleID, _, err := resolveApp(cmd) + if err != nil { + return nil, nil, err + } + ctx, cancel := commandContext(cmd, false) + app, err := client.AppByBundleID(ctx, bundleID) + if err != nil { + cancel() + return nil, nil, err + } + return &ascSession{ctx: ctx, client: client, app: app, out: newOutput(cmd)}, cancel, nil +} + +// findGroup returns the app's TestFlight group called name (case-insensitive), +// nil when there is none, and the app's groups either way. +func (s *ascSession) findGroup(name string) (*asc.BetaGroup, []asc.BetaGroup, error) { + groups, err := s.client.ListBetaGroups(s.ctx, s.app.ID) + if err != nil { + return nil, nil, err + } + g, err := asc.MatchBetaGroup(groups, name) + return g, groups, err +} + +// group is findGroup for commands that need the group to exist. +func (s *ascSession) group(name string) (*asc.BetaGroup, error) { + g, groups, err := s.findGroup(name) + if err != nil || g != nil { + return g, err + } + has := "(none)" + if len(groups) > 0 { + names := make([]string, 0, len(groups)) + for _, g := range groups { + names = append(names, g.Name) + } + has = strings.Join(names, ", ") + } + return nil, fmt.Errorf("no TestFlight group named %s; %s has: %s", name, s.app.Name, has) +} + +// testerFilter scopes tester lookups to the app, or to --group when given. +func (s *ascSession) testerFilter(cmd *cobra.Command) (*asc.BetaTesterFilter, error) { + name, _ := cmd.Flags().GetString("group") + if name == "" { + return &asc.BetaTesterFilter{AppID: s.app.ID}, nil + } + g, err := s.group(name) + if err != nil { + return nil, err + } + return &asc.BetaTesterFilter{GroupID: g.ID}, nil +} + +// tester finds one of the app's (or group's) testers by email. +func (s *ascSession) tester(f *asc.BetaTesterFilter, email string) (*asc.BetaTester, error) { + scoped := *f + scoped.Email = email + t, err := s.client.FindBetaTester(s.ctx, &scoped) + if err != nil { + return nil, err + } + if t == nil { + return nil, fmt.Errorf("%s has no TestFlight tester %s", s.app.Name, email) + } + return t, nil +} + +// printTable writes rows as aligned columns; the first row is the header. +func printTable(w io.Writer, rows [][]string) { + tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) + for _, r := range rows { + fmt.Fprintln(tw, strings.Join(r, "\t")) + } + _ = tw.Flush() +} + +func yesNo(b bool) string { + if b { + return "yes" + } + return "" +} + +type appRow struct { + ID string `json:"id"` + Name string `json:"name"` + BundleID string `json:"bundle_id"` + SKU string `json:"sku"` +} + +func runASCApps(cmd *cobra.Command, _ []string) error { + client, err := getASCClient() + if err != nil { + return err + } + ctx, cancel := commandContext(cmd, false) + defer cancel() + apps, err := client.ListApps(ctx) + if err != nil { + return err + } + rows := make([]appRow, 0, len(apps)) + for _, a := range apps { + rows = append(rows, appRow{ID: a.ID, Name: a.Name, BundleID: a.BundleID, SKU: a.SKU}) + } + return finish(newOutput(cmd), cmd, &rows, nil, func() { + table := [][]string{{"NAME", "BUNDLE ID", "ID", "SKU"}} + for _, r := range rows { + table = append(table, []string{r.Name, r.BundleID, r.ID, r.SKU}) + } + printTable(cmd.OutOrStdout(), table) + }) +} + +type buildRow struct { + ID string `json:"id"` + Version string `json:"version"` + BuildNumber string `json:"build_number"` + ProcessingState string `json:"processing_state"` + UploadedDate time.Time `json:"uploaded_date"` + Expired bool `json:"expired"` + Groups []string `json:"groups"` +} + +func toBuildRow(b *asc.Build) buildRow { + groups := b.BetaGroups + if groups == nil { + groups = []string{} + } + return buildRow{ID: b.ID, Version: b.Version, BuildNumber: b.BuildNumber, ProcessingState: b.ProcessingState, UploadedDate: b.UploadedDate, Expired: b.Expired, Groups: groups} +} + +func runASCBuilds(cmd *cobra.Command, _ []string) error { + s, cancel, err := openASC(cmd) + if err != nil { + return err + } + defer cancel() + limit, _ := cmd.Flags().GetInt("limit") + allVersions, _ := cmd.Flags().GetBool("all-versions") + f := &asc.BuildFilter{AppID: s.app.ID, Platform: asc.PlatformIOS, Details: true, Limit: limit} + if !allVersions { + newest, err := s.client.ListBuilds(s.ctx, &asc.BuildFilter{AppID: s.app.ID, Platform: asc.PlatformIOS, Details: true, Limit: 1}) + if err != nil { + return err + } + if len(newest) > 0 { + f.Version = newest[0].Version + } + } + builds, err := s.client.ListBuilds(s.ctx, f) + if err != nil { + return err + } + rows := make([]buildRow, 0, len(builds)) + for i := range builds { + rows = append(rows, toBuildRow(&builds[i])) + } + return finish(s.out, cmd, &rows, nil, func() { + if len(rows) == 0 { + fmt.Fprintf(cmd.OutOrStdout(), "%s has no builds; upload one with builder ios upload --wait\n", s.app.Name) + return + } + table := [][]string{{"VERSION", "BUILD", "STATE", "UPLOADED", "EXPIRED", "GROUPS"}} + for _, r := range rows { + table = append(table, []string{r.Version, r.BuildNumber, r.ProcessingState, r.UploadedDate.Local().Format("2006-01-02 15:04"), yesNo(r.Expired), strings.Join(r.Groups, ", ")}) + } + printTable(cmd.OutOrStdout(), table) + }) +} + +func runASCBuildsExpire(cmd *cobra.Command, _ []string) error { + number, _ := cmd.Flags().GetString("build-number") + yes, _ := cmd.Flags().GetBool("yes") + s, cancel, err := openASC(cmd) + if err != nil { + return err + } + defer cancel() + builds, err := s.client.ListBuilds(s.ctx, &asc.BuildFilter{AppID: s.app.ID, Platform: asc.PlatformIOS, BuildNumber: number, Details: true, Limit: 1}) + if err != nil { + return err + } + if len(builds) == 0 { + return fmt.Errorf("%s has no build %s", s.app.Name, number) + } + build := builds[0] + row := toBuildRow(&build) + if build.Expired { + logf(s.out.log, "Build %s of %s (%s) is already expired", build.BuildNumber, build.Version, build.ID) + return finish(s.out, cmd, &row, nil, nil) + } + logf(s.out.log, "Will expire build %s of %s (%s, uploaded %s); it leaves TestFlight for good", build.BuildNumber, build.Version, build.ID, build.UploadedDate.Local().Format("2006-01-02")) + if !yes { + return fmt.Errorf("pass --yes to expire build %s", build.BuildNumber) + } + updated, err := s.client.ExpireBuild(s.ctx, build.ID) + if err != nil { + return err + } + row.Expired = updated.Expired + logf(s.out.log, "Expired build %s (%s)", build.BuildNumber, build.ID) + return finish(s.out, cmd, &row, nil, nil) +} + +type groupRow struct { + ID string `json:"id"` + Name string `json:"name"` + Internal bool `json:"internal"` + // AutoBuilds: an internal group with automatic distribution gets every build. + AutoBuilds bool `json:"auto_builds"` + Testers int `json:"testers"` + PublicLink string `json:"public_link,omitempty"` +} + +func toGroupRow(g *asc.BetaGroup) groupRow { + row := groupRow{ID: g.ID, Name: g.Name, Internal: g.Internal, AutoBuilds: g.Internal && g.HasAccessToAllBuilds} + if g.PublicLinkEnabled { + row.PublicLink = g.PublicLink + } + return row +} + +func (s *ascSession) groupRow(g *asc.BetaGroup) (groupRow, error) { + testers, err := s.client.ListBetaTesters(s.ctx, &asc.BetaTesterFilter{GroupID: g.ID}) + if err != nil { + return groupRow{}, err + } + row := toGroupRow(g) + row.Testers = len(testers) + return row, nil +} + +// groupKind names the group type: "internal, all builds" for automatic distribution. +func groupKind(g *asc.BetaGroup) string { + switch { + case !g.Internal: + return "external" + case g.HasAccessToAllBuilds: + return "internal, all builds" + } + return "internal" +} + +func runASCGroups(cmd *cobra.Command, _ []string) error { + s, cancel, err := openASC(cmd) + if err != nil { + return err + } + defer cancel() + groups, err := s.client.ListBetaGroups(s.ctx, s.app.ID) + if err != nil { + return err + } + rows := make([]groupRow, 0, len(groups)) + kinds := make([]string, 0, len(groups)) + for i := range groups { + row, err := s.groupRow(&groups[i]) + if err != nil { + return err + } + rows = append(rows, row) + kinds = append(kinds, groupKind(&groups[i])) + } + return finish(s.out, cmd, &rows, nil, func() { + if len(rows) == 0 { + fmt.Fprintf(cmd.OutOrStdout(), "%s has no TestFlight groups; create one with builder asc groups create \n", s.app.Name) + return + } + table := [][]string{{"NAME", "TYPE", "TESTERS", "PUBLIC LINK"}} + for i, r := range rows { + table = append(table, []string{r.Name, kinds[i], fmt.Sprint(r.Testers), r.PublicLink}) + } + printTable(cmd.OutOrStdout(), table) + }) +} + +func runASCGroupsCreate(cmd *cobra.Command, args []string) error { + name := args[0] + external, _ := cmd.Flags().GetBool("external") + publicLink, _ := cmd.Flags().GetBool("public-link") + noAutoBuilds, _ := cmd.Flags().GetBool("no-auto-builds") + if publicLink && !external { + return fmt.Errorf("public links are only available on external groups; add --external") + } + if noAutoBuilds && external { + return fmt.Errorf("--no-auto-builds only applies to internal groups; external groups always take builds by hand") + } + s, cancel, err := openASC(cmd) + if err != nil { + return err + } + defer cancel() + if g, _, err := s.findGroup(name); err != nil { + return err + } else if g != nil { + return fmt.Errorf("%s already has a TestFlight group named %s (%s)", s.app.Name, g.Name, groupKind(g)) + } + g, err := s.client.CreateBetaGroup(s.ctx, asc.BetaGroupSpec{AppID: s.app.ID, Name: name, Internal: !external, PublicLinkEnabled: publicLink, HasAccessToAllBuilds: !external && !noAutoBuilds}) + if err != nil { + return fmt.Errorf("create TestFlight group %s: %w", name, err) + } + logf(s.out.log, "Created TestFlight group %s (%s)", g.Name, groupKind(g)) + if g.PublicLinkEnabled && g.PublicLink != "" { + logf(s.out.log, "Public link: %s", g.PublicLink) + } + row := toGroupRow(g) + return finish(s.out, cmd, &row, nil, nil) +} + +func runASCGroupsDelete(cmd *cobra.Command, args []string) error { + s, cancel, err := openASC(cmd) + if err != nil { + return err + } + defer cancel() + g, err := s.group(args[0]) + if err != nil { + return err + } + row, err := s.groupRow(g) + if err != nil { + return err + } + logf(s.out.log, "Will delete TestFlight group %s (%s, %d testers, %s); its testers stay on the team", g.Name, groupKind(g), row.Testers, g.ID) + if yes, _ := cmd.Flags().GetBool("yes"); !yes { + return fmt.Errorf("pass --yes to delete TestFlight group %s", g.Name) + } + if err := s.client.DeleteBetaGroup(s.ctx, g.ID); err != nil { + return fmt.Errorf("delete TestFlight group %s: %w", g.Name, err) + } + logf(s.out.log, "Deleted TestFlight group %s", g.Name) + return finish(s.out, cmd, &row, nil, nil) +} + +func runASCGroupsAddBuild(cmd *cobra.Command, args []string) error { + s, cancel, err := openASC(cmd) + if err != nil { + return err + } + defer cancel() + buildNumber, _ := cmd.Flags().GetString("build-number") + noEncryption, _ := cmd.Flags().GetBool("no-encryption") + return runTestFlight(s.ctx, cmd, s.client, s.out, &distribute.TestFlightOptions{ + BundleID: s.app.BundleID, BuildNumber: buildNumber, Groups: []string{args[0]}, NoEncryption: noEncryption, Log: s.out.log, + }) +} + +type testerRow struct { + ID string `json:"id"` + Email string `json:"email"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + State string `json:"state"` +} + +func runASCTesters(cmd *cobra.Command, _ []string) error { + s, cancel, err := openASC(cmd) + if err != nil { + return err + } + defer cancel() + f, err := s.testerFilter(cmd) + if err != nil { + return err + } + testers, err := s.client.ListBetaTesters(s.ctx, f) + if err != nil { + return err + } + rows := make([]testerRow, 0, len(testers)) + notInvited := 0 + for _, t := range testers { + rows = append(rows, toTesterRow(&t)) + if t.State == asc.BetaTesterNotInvited { + notInvited++ + } + } + return finish(s.out, cmd, &rows, nil, func() { + if len(rows) == 0 { + fmt.Fprintln(cmd.OutOrStdout(), "No testers; invite some with builder asc testers add --group ") + return + } + table := [][]string{{"EMAIL", "FIRST", "LAST", "STATE"}} + for _, r := range rows { + table = append(table, []string{r.Email, r.FirstName, r.LastName, r.State}) + } + printTable(cmd.OutOrStdout(), table) + if notInvited > 0 { + fmt.Fprintf(cmd.OutOrStdout(), "%d NOT_INVITED: no email has gone out; send it with builder asc testers invite \n", notInvited) + } + }) +} + +func toTesterRow(t *asc.BetaTester) testerRow { + return testerRow{ID: t.ID, Email: t.Email, FirstName: t.FirstName, LastName: t.LastName, State: t.State} +} + +type inviteRow struct { + testerRow + // Invited is set when an invitation email was sent by this run. + Invited bool `json:"invited"` +} + +func runASCTestersInvite(cmd *cobra.Command, emails []string) error { + s, cancel, err := openASC(cmd) + if err != nil { + return err + } + defer cancel() + f, err := s.testerFilter(cmd) + if err != nil { + return err + } + rows := make([]inviteRow, 0, len(emails)) + for _, email := range emails { + t, err := s.tester(f, email) + if err != nil { + return err + } + row := inviteRow{testerRow: toTesterRow(t)} + switch t.State { + case asc.BetaTesterNotInvited, asc.BetaTesterInvited: + if t, err = distribute.InviteTester(s.ctx, s.client, s.out.log, s.app.ID, t); err != nil { + return err + } + row.State, row.Invited = t.State, true + default: + logf(s.out.log, "%s is %s; no invitation sent", t.Email, t.State) + } + rows = append(rows, row) + } + return finish(s.out, cmd, &rows, nil, nil) +} + +func runASCTestersAdd(cmd *cobra.Command, emails []string) error { + s, cancel, err := openASC(cmd) + if err != nil { + return err + } + defer cancel() + groupName, _ := cmd.Flags().GetString("group") + first, _ := cmd.Flags().GetString("first") + last, _ := cmd.Flags().GetString("last") + role, _ := cmd.Flags().GetString("role") + g, err := s.group(groupName) + if err != nil { + return err + } + rows := make([]distribute.TesterResult, 0, len(emails)) + for _, email := range emails { + res, err := distribute.AddTester(s.ctx, s.client, &distribute.TesterOptions{ + AppID: s.app.ID, Group: *g, Email: email, FirstName: first, LastName: last, TeamRole: teamRole(role), Log: s.out.log, + }) + if err != nil { + return fmt.Errorf("add %s: %w", email, err) + } + rows = append(rows, *res) + } + return finish(s.out, cmd, &rows, nil, nil) +} + +// teamRole normalizes a --role value to App Store Connect's spelling (APP_MANAGER). +func teamRole(role string) string { + return strings.ToUpper(strings.ReplaceAll(strings.TrimSpace(role), "-", "_")) +} + +type userRow struct { + ID string `json:"id"` + Email string `json:"email"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + Roles []string `json:"roles"` + // TestFlight reports whether the member has a beta tester record, i.e. + // can be put into internal groups. + TestFlight bool `json:"testflight"` +} + +func runASCUsers(cmd *cobra.Command, _ []string) error { + client, err := getASCClient() + if err != nil { + return err + } + ctx, cancel := commandContext(cmd, false) + defer cancel() + users, err := client.ListUsers(ctx) + if err != nil { + return err + } + testers, err := client.ListBetaTesters(ctx, &asc.BetaTesterFilter{}) + if err != nil { + return err + } + hasTester := make(map[string]bool, len(testers)) + for _, t := range testers { + hasTester[strings.ToLower(t.Email)] = true + } + rows := make([]userRow, 0, len(users)) + for _, u := range users { + roles := u.Roles + if roles == nil { + roles = []string{} + } + rows = append(rows, userRow{ID: u.ID, Email: u.Email, FirstName: u.FirstName, LastName: u.LastName, Roles: roles, TestFlight: hasTester[strings.ToLower(u.Email)]}) + } + return finish(newOutput(cmd), cmd, &rows, nil, func() { + table := [][]string{{"EMAIL", "NAME", "ROLES", "TESTFLIGHT"}} + for _, r := range rows { + table = append(table, []string{r.Email, strings.TrimSpace(r.FirstName + " " + r.LastName), strings.Join(r.Roles, ","), yesNo(r.TestFlight)}) + } + printTable(cmd.OutOrStdout(), table) + }) +} + +type invitationRow struct { + ID string `json:"id"` + Email string `json:"email"` + Roles []string `json:"roles"` + Expires time.Time `json:"expires"` + // Pending is set when an unaccepted invitation already existed and no new one was sent. + Pending bool `json:"pending,omitempty"` +} + +func runASCUsersInvite(cmd *cobra.Command, args []string) error { + email := args[0] + first, _ := cmd.Flags().GetString("first") + last, _ := cmd.Flags().GetString("last") + role, _ := cmd.Flags().GetString("role") + allApps, _ := cmd.Flags().GetBool("all-apps") + if first == "" || last == "" { + return fmt.Errorf("a team invitation needs a first and last name; pass --first and --last") + } + s, cancel, err := openASC(cmd) + if err != nil { + return err + } + defer cancel() + if u, err := s.client.FindUser(s.ctx, email); err != nil { + return err + } else if u != nil { + return fmt.Errorf("%s is already on the team (%s)", u.Email, strings.Join(u.Roles, ",")) + } + inv, err := s.client.FindUserInvitation(s.ctx, email) + if err != nil { + return err + } + pending := inv != nil + if pending { + logf(s.out.log, "%s already has a pending team invitation (expires %s)", inv.Email, inv.ExpirationDate.Local().Format("2006-01-02")) + } else { + inv, err = s.client.InviteUser(s.ctx, &asc.UserInvitationSpec{Email: email, FirstName: first, LastName: last, Roles: []string{teamRole(role)}, AllAppsVisible: allApps, VisibleAppIDs: []string{s.app.ID}}) + if err != nil { + return fmt.Errorf("invite %s: %w", email, err) + } + logf(s.out.log, "Invited %s to the team as %s; they must accept the email to join", inv.Email, teamRole(role)) + } + row := invitationRow{ID: inv.ID, Email: inv.Email, Roles: inv.Roles, Expires: inv.ExpirationDate, Pending: pending} + if row.Roles == nil { + row.Roles = []string{} + } + return finish(s.out, cmd, &row, nil, nil) +} + +type testerRemoveRow struct { + ID string `json:"id"` + Email string `json:"email"` + // Group is the group left; empty when the tester was removed from TestFlight. + Group string `json:"group,omitempty"` +} + +func runASCTestersRemove(cmd *cobra.Command, emails []string) error { + groupName, _ := cmd.Flags().GetString("group") + yes, _ := cmd.Flags().GetBool("yes") + if groupName == "" && !yes { + return fmt.Errorf("pass --group to remove the testers from one group, or --yes to remove them from TestFlight entirely") + } + s, cancel, err := openASC(cmd) + if err != nil { + return err + } + defer cancel() + var g *asc.BetaGroup + f := &asc.BetaTesterFilter{AppID: s.app.ID} + if groupName != "" { + if g, err = s.group(groupName); err != nil { + return err + } + f = &asc.BetaTesterFilter{GroupID: g.ID} + } + // Resolve every address before touching anything, so a typo in the + // second one does not leave the first half removed. + rows := make([]testerRemoveRow, 0, len(emails)) + ids := make([]string, 0, len(emails)) + for _, email := range emails { + t, err := s.tester(f, email) + if err != nil { + return err + } + rows = append(rows, testerRemoveRow{ID: t.ID, Email: t.Email}) + ids = append(ids, t.ID) + } + if g != nil { + if err := s.client.RemoveBetaTestersFromGroup(s.ctx, g.ID, ids); err != nil { + return fmt.Errorf("remove from %s: %w", g.Name, err) + } + for i := range rows { + rows[i].Group = g.Name + logf(s.out.log, "Removed %s from %s", rows[i].Email, g.Name) + } + return finish(s.out, cmd, &rows, nil, nil) + } + for _, r := range rows { + logf(s.out.log, "Will remove %s (%s) from TestFlight for the whole team: every app and every group", r.Email, r.ID) + } + for _, r := range rows { + if err := s.client.DeleteBetaTester(s.ctx, r.ID); err != nil { + return fmt.Errorf("remove %s: %w", r.Email, err) + } + logf(s.out.log, "Removed %s from TestFlight", r.Email) + } + return finish(s.out, cmd, &rows, nil, nil) +} + +// logf writes a progress line when w is set. +func logf(w io.Writer, format string, args ...any) { + if w != nil { + fmt.Fprintf(w, format+"\n", args...) + } +} diff --git a/cmd/builder/asc_test.go b/cmd/builder/asc_test.go new file mode 100644 index 0000000..6daacd1 --- /dev/null +++ b/cmd/builder/asc_test.go @@ -0,0 +1,437 @@ +package main + +import ( + "bytes" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "encoding/json" + "encoding/pem" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/MobAI-App/ios-builder/internal/asc" + "github.com/MobAI-App/ios-builder/internal/distribute" + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +// ascFake is an in-memory App Store Connect covering what the command tests +// touch: one app, one processed build, two groups, an accepted tester and a +// NOT_INVITED one. +type ascFake struct { + t *testing.T + mu sync.Mutex + calls []string + bodies map[string]map[string]any + // dupGroup adds a second group whose name folds to "beta testers". + dupGroup bool + // quietState is the NOT_INVITED tester's state; an invitation flips it, + // unless noBuilds (no group has a build) makes App Store Connect refuse. + quietState string + noBuilds bool +} + +func newASCFake(t *testing.T) *ascFake { + t.Helper() + f := &ascFake{t: t, bodies: map[string]map[string]any{}, quietState: "NOT_INVITED"} + res := func(typ, id string, attrs map[string]any) map[string]any { + return map[string]any{"type": typ, "id": id, "attributes": attrs} + } + one := func(w http.ResponseWriter, status int, r any) { writeJSON(w, status, map[string]any{"data": r}) } + many := func(w http.ResponseWriter, rs ...any) { + if rs == nil { + rs = []any{} + } + writeJSON(w, 200, map[string]any{"data": rs}) + } + mux := http.NewServeMux() + handle := func(pattern string, h func(w http.ResponseWriter, r *http.Request, body map[string]any)) { + mux.HandleFunc(pattern, func(w http.ResponseWriter, r *http.Request) { + var body map[string]any + data, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(data, &body) + f.mu.Lock() + defer f.mu.Unlock() + key := r.Method + " " + r.URL.Path + f.calls = append(f.calls, key) + if body != nil { + f.bodies[key] = body + } + h(w, r, body) + }) + } + handle("GET /v1/apps", func(w http.ResponseWriter, r *http.Request, _ map[string]any) { + if r.URL.Query().Get("filter[bundleId]") != "com.example.app" { + many(w) + return + } + many(w, res("apps", "app-1", map[string]any{"bundleId": "com.example.app", "name": "Example", "primaryLocale": "en-US"})) + }) + handle("GET /v1/builds", func(w http.ResponseWriter, r *http.Request, _ map[string]any) { + many(w, res("builds", "build-9", map[string]any{"version": "7", "processingState": "VALID", "uploadedDate": "2026-09-16T10:00:00Z", "expired": false, "usesNonExemptEncryption": false})) + }) + handle("GET /v1/betaGroups", func(w http.ResponseWriter, r *http.Request, _ map[string]any) { + // The internal group mirrors a real one made in the UI: automatic distribution, null public link. + groups := []any{ + res("betaGroups", "g-int", map[string]any{"name": "Team", "isInternalGroup": true, "hasAccessToAllBuilds": true, "publicLinkEnabled": nil}), + res("betaGroups", "g-ext", map[string]any{"name": "Beta Testers", "isInternalGroup": false}), + } + if f.dupGroup { + groups = append(groups, res("betaGroups", "g-dup", map[string]any{"name": "beta testers", "isInternalGroup": false})) + } + many(w, groups...) + }) + handle("POST /v1/betaGroups", func(w http.ResponseWriter, r *http.Request, body map[string]any) { + one(w, 201, res("betaGroups", "g-new", obj(t, body, "data", "attributes"))) + }) + handle("DELETE /v1/betaGroups/{id}", func(w http.ResponseWriter, r *http.Request, _ map[string]any) { w.WriteHeader(204) }) + handle("POST /v1/builds/{id}/relationships/betaGroups", func(w http.ResponseWriter, r *http.Request, _ map[string]any) { w.WriteHeader(204) }) + handle("POST /v1/betaGroups/{id}/relationships/betaTesters", func(w http.ResponseWriter, r *http.Request, _ map[string]any) { w.WriteHeader(204) }) + handle("DELETE /v1/betaGroups/{id}/relationships/betaTesters", func(w http.ResponseWriter, r *http.Request, _ map[string]any) { w.WriteHeader(204) }) + old := func() map[string]any { + return res("betaTesters", "t-old", map[string]any{"email": "old@example.com", "firstName": "Old", "inviteType": "EMAIL", "state": "ACCEPTED"}) + } + quiet := func() map[string]any { + return res("betaTesters", "t-quiet", map[string]any{"email": "quiet@example.com", "inviteType": "EMAIL", "state": f.quietState}) + } + handle("GET /v1/betaTesters", func(w http.ResponseWriter, r *http.Request, _ map[string]any) { + switch r.URL.Query().Get("filter[email]") { + case "old@example.com": + many(w, old()) + case "quiet@example.com": + many(w, quiet()) + case "": + many(w, old(), quiet()) + default: + many(w) + } + }) + handle("GET /v1/betaTesters/{id}", func(w http.ResponseWriter, r *http.Request, _ map[string]any) { + switch r.PathValue("id") { + case "t-old": + one(w, 200, old()) + case "t-quiet": + one(w, 200, quiet()) + default: + w.WriteHeader(404) + } + }) + handle("DELETE /v1/betaTesters/{id}", func(w http.ResponseWriter, r *http.Request, _ map[string]any) { w.WriteHeader(204) }) + handle("POST /v1/betaTesterInvitations", func(w http.ResponseWriter, r *http.Request, body map[string]any) { + if f.noBuilds { + writeJSON(w, 409, map[string]any{"errors": []map[string]any{{"status": "409", "code": "STATE_ERROR.TESTER_INVITE.NO_INSTALLABLE_BUILDS", "title": "The request cannot be fulfilled because of the state of another resource."}}}) + return + } + if obj(t, body, "data", "relationships", "betaTester", "data")["id"] == "t-quiet" { + f.quietState = "INVITED" + } + one(w, 201, map[string]any{"type": "betaTesterInvitations", "id": "bti-1"}) + }) + handle("POST /v1/betaTesters", func(w http.ResponseWriter, r *http.Request, body map[string]any) { + attrs := obj(t, body, "data", "attributes") + if attrs["email"] == "old@example.com" { + writeJSON(w, 409, map[string]any{"errors": []map[string]any{{"status": "409", "code": "ENTITY_ERROR.ATTRIBUTE.INVALID.DUPLICATE", "title": "duplicate"}}}) + return + } + one(w, 201, res("betaTesters", "t-new", map[string]any{"email": attrs["email"], "state": "INVITED"})) + }) + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + t.Errorf("unexpected request %s %s", r.Method, r.URL) + w.WriteHeader(404) + }) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + der, _ := x509.MarshalPKCS8PrivateKey(key) + creds := asc.Credentials{IssuerID: "iss", KeyID: "kid", PrivateKey: string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}))} + previous := getASCClient + getASCClient = func() (*asc.Client, error) { + return asc.NewClient(creds, asc.WithBaseURL(srv.URL), asc.WithRetryDelay(time.Millisecond)) + } + t.Cleanup(func() { getASCClient = previous }) + return f +} + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} + +// obj walks decoded JSON down the given object keys; a missing or non-object +// step fails the test and yields nil, which later lookups tolerate. +func obj(t *testing.T, v any, keys ...string) map[string]any { + t.Helper() + for i := 0; ; i++ { + m, ok := v.(map[string]any) + if !ok { + t.Errorf("JSON path %v: %T is not an object", keys[:i], v) + return nil + } + if i == len(keys) { + return m + } + v = m[keys[i]] + } +} + +// arr is obj for a final array value. +func arr(t *testing.T, v any, keys ...string) []any { + t.Helper() + a, ok := obj(t, v, keys[:len(keys)-1]...)[keys[len(keys)-1]].([]any) + if !ok { + t.Errorf("JSON path %v is not an array", keys) + } + return a +} + +func (f *ascFake) body(key string) map[string]any { + f.mu.Lock() + defer f.mu.Unlock() + return f.bodies[key] +} + +func (f *ascFake) called(key string) bool { + f.mu.Lock() + defer f.mu.Unlock() + for _, c := range f.calls { + if c == key { + return true + } + } + return false +} + +// run executes a builder command line and returns stdout and stderr. Flag +// values survive Execute on the shared command tree, so they are reset first. +func run(t *testing.T, args ...string) (stdout, stderr string, err error) { + t.Helper() + resetFlags(rootCmd) + var out, errOut bytes.Buffer + rootCmd.SetOut(&out) + rootCmd.SetErr(&errOut) + rootCmd.SetArgs(args) + err = rootCmd.Execute() + return out.String(), errOut.String(), err +} + +func resetFlags(c *cobra.Command) { + c.Flags().VisitAll(func(f *pflag.Flag) { + if sv, ok := f.Value.(pflag.SliceValue); ok { + _ = sv.Replace(nil) + } else { + _ = f.Value.Set(f.DefValue) + } + f.Changed = false + }) + for _, sub := range c.Commands() { + resetFlags(sub) + } +} + +func TestSubmitCreatesMissingGroup(t *testing.T) { + f := newASCFake(t) + stdout, stderr, err := run(t, "ios", "submit", "--testflight", "--group", "Nightly", "--bundle-id", "com.example.app", "--json") + if err != nil { + t.Fatalf("%v\n%s", err, stderr) + } + var res distribute.TestFlightResult + if err := json.Unmarshal([]byte(stdout), &res); err != nil { + t.Fatalf("stdout is not the JSON result: %v\n%s", err, stdout) + } + if len(res.Groups) != 1 || res.Groups[0].Name != "Nightly" || !res.Groups[0].Created || !res.Groups[0].Internal || res.Groups[0].ID != "g-new" { + t.Errorf("groups = %+v", res.Groups) + } + attrs := obj(t, f.body("POST /v1/betaGroups"), "data", "attributes") + if attrs["name"] != "Nightly" || attrs["isInternalGroup"] != true { + t.Errorf("create body = %v", attrs) + } + if !f.called("POST /v1/builds/build-9/relationships/betaGroups") { + t.Errorf("build not added: %v", f.calls) + } + if !strings.Contains(stderr, "Created TestFlight group Nightly (internal)") { + t.Errorf("stderr = %q", stderr) + } +} + +func TestASCTestersAddExistingTester(t *testing.T) { + f := newASCFake(t) + stdout, stderr, err := run(t, "asc", "testers", "add", "old@example.com", "new@example.com", "--group", "beta testers", "--bundle-id", "com.example.app", "--json") + if err != nil { + t.Fatalf("%v\n%s", err, stderr) + } + var rows []distribute.TesterResult + if err := json.Unmarshal([]byte(stdout), &rows); err != nil { + t.Fatalf("stdout is not a JSON array: %v\n%s", err, stdout) + } + if len(rows) != 2 || rows[0].Status != distribute.TesterAdded || rows[0].ID != "t-old" || rows[1].Status != distribute.TesterInvited || rows[1].ID != "t-new" || rows[0].Group != "Beta Testers" { + t.Errorf("rows = %+v", rows) + } + links := arr(t, f.body("POST /v1/betaGroups/g-ext/relationships/betaTesters"), "data") + if len(links) != 1 || obj(t, links[0])["id"] != "t-old" { + t.Errorf("existing tester linkage = %v", links) + } + if !strings.Contains(stderr, "Added existing tester old@example.com to Beta Testers") || !strings.Contains(stderr, "Invited new@example.com to Beta Testers") { + t.Errorf("stderr = %q", stderr) + } + if stdout != "" && strings.Contains(stdout, "Added existing") { + t.Errorf("progress leaked into stdout: %q", stdout) + } +} + +func TestASCTestersInvite(t *testing.T) { + f := newASCFake(t) + stdout, stderr, err := run(t, "asc", "testers", "invite", "quiet@example.com", "old@example.com", "--bundle-id", "com.example.app", "--json") + if err != nil { + t.Fatalf("%v\n%s", err, stderr) + } + var rows []inviteRow + if err := json.Unmarshal([]byte(stdout), &rows); err != nil { + t.Fatalf("stdout is not a JSON array: %v\n%s", err, stdout) + } + if len(rows) != 2 || rows[0].ID != "t-quiet" || rows[0].State != "INVITED" || !rows[0].Invited || rows[1].ID != "t-old" || rows[1].State != "ACCEPTED" || rows[1].Invited { + t.Errorf("rows = %+v", rows) + } + invite := obj(t, f.body("POST /v1/betaTesterInvitations"), "data") + if obj(t, invite, "relationships", "app", "data")["id"] != "app-1" || obj(t, invite, "relationships", "betaTester", "data")["id"] != "t-quiet" { + t.Errorf("invitation = %v", invite) + } + if !strings.Contains(stderr, "Sent TestFlight invitation to quiet@example.com (INVITED)") || !strings.Contains(stderr, "old@example.com is ACCEPTED; no invitation sent") { + t.Errorf("stderr = %q", stderr) + } + if !strings.Contains(stdout, `"invited": true`) || strings.Contains(stdout, "Sent TestFlight") { + t.Errorf("stdout = %q", stdout) + } + + // An address the app does not have is an error before anything is sent. + f = newASCFake(t) + if _, _, err := run(t, "asc", "testers", "invite", "nobody@example.com", "--bundle-id", "com.example.app"); err == nil || !strings.Contains(err.Error(), "no TestFlight tester nobody@example.com") || f.called("POST /v1/betaTesterInvitations") { + t.Errorf("err = %v, calls = %v", err, f.calls) + } + + // A group without a build: Apple's 409 becomes what to do next. + f = newASCFake(t) + f.noBuilds = true + if _, _, err := run(t, "asc", "testers", "invite", "quiet@example.com", "--bundle-id", "com.example.app"); err == nil || !strings.Contains(err.Error(), "quiet@example.com has no installable build yet: add one to the group first (builder asc groups add-build )") || strings.Contains(err.Error(), "STATE_ERROR") { + t.Errorf("err = %v", err) + } +} + +func TestASCTestersListHintsNotInvited(t *testing.T) { + newASCFake(t) + stdout, _, err := run(t, "asc", "testers", "--bundle-id", "com.example.app") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(stdout, "EMAIL") || !strings.Contains(stdout, "NOT_INVITED") || !strings.Contains(stdout, "builder asc testers invite") { + t.Errorf("stdout = %q", stdout) + } + stdout, _, err = run(t, "asc", "testers", "--bundle-id", "com.example.app", "--json") + if err != nil { + t.Fatal(err) + } + var rows []testerRow + if err := json.Unmarshal([]byte(stdout), &rows); err != nil || len(rows) != 2 || rows[1].State != "NOT_INVITED" { + t.Errorf("rows = %+v, err = %v\n%s", rows, err, stdout) + } +} + +func TestASCGroupsDeleteNeedsYes(t *testing.T) { + f := newASCFake(t) + stdout, _, err := run(t, "asc", "groups", "delete", "beta testers", "--bundle-id", "com.example.app") + if err == nil || !strings.Contains(err.Error(), "--yes") || f.called("DELETE /v1/betaGroups/g-ext") { + t.Errorf("err = %v, calls = %v", err, f.calls) + } + if !strings.Contains(stdout, "Will delete TestFlight group Beta Testers (external, 2 testers, g-ext)") { + t.Errorf("the preview must name the group: %q", stdout) + } + stdout, stderr, err := run(t, "asc", "groups", "delete", "beta testers", "--yes", "--bundle-id", "com.example.app", "--json") + if err != nil || !f.called("DELETE /v1/betaGroups/g-ext") { + t.Fatalf("err = %v, calls = %v", err, f.calls) + } + var row groupRow + if err := json.Unmarshal([]byte(stdout), &row); err != nil || row.ID != "g-ext" || row.Testers != 2 { + t.Errorf("row = %+v, err = %v\n%s", row, err, stdout) + } + if !strings.Contains(stderr, "Deleted TestFlight group Beta Testers") { + t.Errorf("stderr = %q", stderr) + } +} + +func TestASCTestersRemoveTeamWideNeedsYes(t *testing.T) { + f := newASCFake(t) + _, _, err := run(t, "asc", "testers", "remove", "old@example.com", "--bundle-id", "com.example.app") + if err == nil || !strings.Contains(err.Error(), "--yes") || len(f.calls) != 0 { + t.Errorf("err = %v, calls = %v", err, f.calls) + } + // Every address is resolved before the first deletion. + _, _, err = run(t, "asc", "testers", "remove", "old@example.com", "nobody@example.com", "--yes", "--bundle-id", "com.example.app") + if err == nil || !strings.Contains(err.Error(), "nobody@example.com") || f.called("DELETE /v1/betaTesters/t-old") { + t.Errorf("err = %v, calls = %v", err, f.calls) + } + stdout, _, err := run(t, "asc", "testers", "remove", "old@example.com", "--yes", "--bundle-id", "com.example.app") + if err != nil || !f.called("DELETE /v1/betaTesters/t-old") { + t.Fatalf("err = %v, calls = %v", err, f.calls) + } + if !strings.Contains(stdout, "Will remove old@example.com (t-old) from TestFlight for the whole team") { + t.Errorf("stdout = %q", stdout) + } + + // With --group only the linkage goes. + f = newASCFake(t) + stdout, _, err = run(t, "asc", "testers", "remove", "old@example.com", "--group", "Beta Testers", "--bundle-id", "com.example.app", "--json") + if err != nil || f.called("DELETE /v1/betaTesters/t-old") { + t.Fatalf("err = %v, calls = %v", err, f.calls) + } + if links := arr(t, f.body("DELETE /v1/betaGroups/g-ext/relationships/betaTesters"), "data"); len(links) != 1 || obj(t, links[0])["id"] != "t-old" { + t.Errorf("linkage = %v", links) + } + var rows []testerRemoveRow + if err := json.Unmarshal([]byte(stdout), &rows); err != nil || len(rows) != 1 || rows[0].Group != "Beta Testers" { + t.Errorf("rows = %+v, err = %v\n%s", rows, err, stdout) + } +} + +func TestASCAmbiguousGroupIsRefused(t *testing.T) { + f := newASCFake(t) + f.dupGroup = true + for _, args := range [][]string{ + {"asc", "groups", "delete", "BETA TESTERS", "--yes"}, + {"asc", "testers", "remove", "old@example.com", "--group", "beta testers"}, + {"asc", "testers", "add", "new@example.com", "--group", "beta testers"}, + {"asc", "groups", "add-build", "beta testers"}, + } { + _, _, err := run(t, append(args, "--bundle-id", "com.example.app")...) + if err == nil || !strings.Contains(err.Error(), "Beta Testers (g-ext)") || !strings.Contains(err.Error(), "beta testers (g-dup)") { + t.Errorf("%v: err = %v", args, err) + } + } + for _, c := range f.calls { + if strings.HasPrefix(c, "DELETE") || c == "POST /v1/betaTesters" || strings.HasSuffix(c, "/relationships/betaGroups") { + t.Errorf("ambiguous name must change nothing: %v", f.calls) + } + } + // A unique case-insensitive match still works. + if _, _, err := run(t, "asc", "testers", "--group", "TEAM", "--bundle-id", "com.example.app"); err != nil { + t.Error(err) + } +} + +func TestASCResolvesAppOrExplains(t *testing.T) { + newASCFake(t) + _, _, err := run(t, "asc", "groups") + if err == nil || !strings.Contains(err.Error(), "--bundle-id") || !strings.Contains(err.Error(), "builder.json") { + t.Errorf("err = %v", err) + } +} diff --git a/cmd/builder/root.go b/cmd/builder/root.go index 87c0d51..e38ac4e 100644 --- a/cmd/builder/root.go +++ b/cmd/builder/root.go @@ -565,6 +565,7 @@ func init() { rootCmd.AddCommand(initCmd) rootCmd.AddCommand(updateCmd) rootCmd.AddCommand(iosCmd) + rootCmd.AddCommand(ascCmd) rootCmd.AddCommand(authCmd) rootCmd.AddCommand(signingCmd) rootCmd.AddCommand(devCmd) diff --git a/cmd/builder/submit.go b/cmd/builder/submit.go index c3e664a..f7701ea 100644 --- a/cmd/builder/submit.go +++ b/cmd/builder/submit.go @@ -1,12 +1,12 @@ package main import ( + "context" "fmt" "time" "github.com/MobAI-App/ios-builder/internal/asc" "github.com/MobAI-App/ios-builder/internal/distribute" - "github.com/MobAI-App/ios-builder/internal/ipa" "github.com/spf13/cobra" ) @@ -17,15 +17,17 @@ var iosSubmitCmd = &cobra.Command{ --testflight adds the build to the named TestFlight groups (--group, repeatable), sets the "What to Test" notes (--notes) and, for external groups, - submits the build for beta review. Without --group it reports the - build and lists the available groups. + submits the build for beta review. A group that does not exist is + created (internal, or external with --external). Without --group + it reports the build and lists the available groups. --app-store finds or creates the App Store version for the marketing version, attaches the build, sets the release type and submits it for review. The version's metadata (description, screenshots, pricing, privacy) must already be complete in App Store Connect. -The app is identified by the IPA in ./dist (or --ipa), or by --bundle-id. The -newest VALID build is used unless --build-number is given.`, +The app is identified by --bundle-id, else --ipa, else ios.bundleId in +builder.json, else the newest IPA in ./dist. The newest VALID build is used +unless --build-number is given.`, Args: cobra.NoArgs, RunE: runIOSSubmit, } @@ -36,8 +38,9 @@ func init() { iosSubmitCmd.Flags().String("ipa", "", "IPA whose bundle ID and version identify the app (default: newest .ipa in ./dist)") iosSubmitCmd.Flags().String("bundle-id", "", "App bundle ID, instead of reading an IPA") iosSubmitCmd.Flags().String("build-number", "", "Build number (CFBundleVersion) to use (default: newest VALID build)") - iosSubmitCmd.Flags().String("version", "", "Marketing version (default: from the IPA; required with --app-store and --bundle-id)") - iosSubmitCmd.Flags().StringArray("group", nil, "TestFlight group name to add the build to (repeatable)") + iosSubmitCmd.Flags().String("version", "", "Marketing version (default: from the IPA; required with --app-store when no IPA is read)") + iosSubmitCmd.Flags().StringArray("group", nil, "TestFlight group name to add the build to (repeatable; created if missing)") + iosSubmitCmd.Flags().Bool("external", false, "Create missing --group names as external groups (default: internal)") iosSubmitCmd.Flags().String("notes", "", "What to Test notes for the build") iosSubmitCmd.Flags().String("locale", "", "Locale for --notes (default: the app's primary locale)") iosSubmitCmd.Flags().String("release", "", "App Store release: manual or after-approval") @@ -58,21 +61,13 @@ func runIOSSubmit(cmd *cobra.Command, _ []string) error { if err != nil { return err } - bundleID, _ := cmd.Flags().GetString("bundle-id") + bundleID, ipaVersion, err := resolveApp(cmd) + if err != nil { + return err + } version, _ := cmd.Flags().GetString("version") - if bundleID == "" { - ipaPath, _ := cmd.Flags().GetString("ipa") - if ipaPath, err = resolveIPA(ipaPath); err != nil { - return fmt.Errorf("%w (or pass --bundle-id)", err) - } - info, err := ipa.ReadInfo(ipaPath) - if err != nil { - return err - } - bundleID = info.BundleID - if version == "" && appStore { - version = info.Version - } + if version == "" && appStore { + version = ipaVersion } buildNumber, _ := cmd.Flags().GetString("build-number") noEncryption, _ := cmd.Flags().GetBool("no-encryption") @@ -83,20 +78,13 @@ func runIOSSubmit(cmd *cobra.Command, _ []string) error { if testflight { groups, _ := cmd.Flags().GetStringArray("group") + external, _ := cmd.Flags().GetBool("external") notes, _ := cmd.Flags().GetString("notes") locale, _ := cmd.Flags().GetString("locale") - res, err := distribute.SubmitTestFlight(ctx, client, &distribute.TestFlightOptions{ - BundleID: bundleID, Version: version, BuildNumber: buildNumber, Groups: groups, Notes: notes, Locale: locale, + return runTestFlight(ctx, cmd, client, out, &distribute.TestFlightOptions{ + BundleID: bundleID, Version: version, BuildNumber: buildNumber, Groups: groups, External: external, Notes: notes, Locale: locale, NoEncryption: noEncryption, Wait: wait, Log: out.log, }) - return finish(out, cmd, res, err, func() { - fmt.Println() - fmt.Printf("Build ID: %s (build %s)\n", res.Build.ID, res.Build.BuildNumber) - if res.BetaReview != nil { - fmt.Printf("Beta review: %s\n", res.BetaReview.State) - } - fmt.Printf("Link: %s\n", res.Link) - }) } releaseFlag, _ := cmd.Flags().GetString("release") @@ -116,6 +104,21 @@ func runIOSSubmit(cmd *cobra.Command, _ []string) error { }) } +// runTestFlight hands the build to TestFlight and prints the outcome; ios +// submit --testflight and asc groups add-build share it. +func runTestFlight(ctx context.Context, cmd *cobra.Command, client *asc.Client, out output, opts *distribute.TestFlightOptions) error { + res, err := distribute.SubmitTestFlight(ctx, client, opts) + return finish(out, cmd, res, err, func() { + w := cmd.OutOrStdout() + fmt.Fprintln(w) + fmt.Fprintf(w, "Build ID: %s (build %s)\n", res.Build.ID, res.Build.BuildNumber) + if res.BetaReview != nil { + fmt.Fprintf(w, "Beta review: %s\n", res.BetaReview.State) + } + fmt.Fprintf(w, "Link: %s\n", res.Link) + }) +} + func parseReleaseType(flag string) (string, error) { switch flag { case "": diff --git a/cmd/builder/upload.go b/cmd/builder/upload.go index 606c211..74a6d2d 100644 --- a/cmd/builder/upload.go +++ b/cmd/builder/upload.go @@ -45,7 +45,9 @@ func init() { iosCmd.AddCommand(iosUploadCmd) } -func getASCClient() (*asc.Client, error) { +// getASCClient builds an App Store Connect client from the saved Apple login +// or the ASC_* environment variables. Tests point it at a fake server. +var getASCClient = func() (*asc.Client, error) { creds, _, err := auth.GetAppleCredentials() if err != nil { if errors.Is(err, auth.ErrNotAuthenticated) { diff --git a/internal/asc/apps.go b/internal/asc/apps.go index d9cf135..97e488f 100644 --- a/internal/asc/apps.go +++ b/internal/asc/apps.go @@ -42,6 +42,19 @@ func (c *Client) AppByBundleID(ctx context.Context, bundleID string) (*App, erro return nil, fmt.Errorf("no App Store Connect app has bundle ID %s; create the app record in App Store Connect (My Apps → +) with that bundle ID first, and check the API key can see it", bundleID) } +// ListApps lists every app the API key can see, by name. +func (c *Client) ListApps(ctx context.Context) ([]App, error) { + rs, err := getAll[appAttributes](ctx, c, "/v1/apps", url.Values{"sort": {"name"}}) + if err != nil { + return nil, err + } + apps := make([]App, 0, len(rs)) + for _, r := range rs { + apps = append(apps, toApp(r)) + } + return apps, nil +} + // CheckAccess makes the cheapest authenticated call to verify the key works. func (c *Client) CheckAccess(ctx context.Context) error { _, err := getPage[appAttributes](ctx, c, "/v1/apps", url.Values{"limit": {"1"}}) diff --git a/internal/asc/betagroups.go b/internal/asc/betagroups.go new file mode 100644 index 0000000..d3b3200 --- /dev/null +++ b/internal/asc/betagroups.go @@ -0,0 +1,127 @@ +package asc + +import ( + "context" + "fmt" + "net/url" + "strings" +) + +// BetaGroup is a TestFlight tester group. +type BetaGroup struct { + ID string + Name string + Internal bool + PublicLinkEnabled bool + // PublicLink is the invitation URL, set when the public link is enabled. + PublicLink string + // HasAccessToAllBuilds marks an internal group with automatic distribution: + // every processed build reaches it, and adding one by hand is refused (422). + HasAccessToAllBuilds bool +} + +type betaGroupAttributes struct { + Name string `json:"name,omitempty"` + IsInternalGroup *bool `json:"isInternalGroup,omitempty"` + PublicLinkEnabled *bool `json:"publicLinkEnabled,omitempty"` + PublicLink string `json:"publicLink,omitempty"` + HasAccessToAllBuilds *bool `json:"hasAccessToAllBuilds,omitempty"` +} + +func toBetaGroup(r Resource[betaGroupAttributes]) BetaGroup { + g := BetaGroup{ID: r.ID, Name: r.Attributes.Name, PublicLink: r.Attributes.PublicLink} + if r.Attributes.IsInternalGroup != nil { + g.Internal = *r.Attributes.IsInternalGroup + } + if r.Attributes.PublicLinkEnabled != nil { + g.PublicLinkEnabled = *r.Attributes.PublicLinkEnabled + } + if r.Attributes.HasAccessToAllBuilds != nil { + g.HasAccessToAllBuilds = *r.Attributes.HasAccessToAllBuilds + } + return g +} + +// ListBetaGroups lists the app's TestFlight groups. +func (c *Client) ListBetaGroups(ctx context.Context, appID string) ([]BetaGroup, error) { + rs, err := getAll[betaGroupAttributes](ctx, c, "/v1/betaGroups", url.Values{"filter[app]": {appID}}) + if err != nil { + return nil, err + } + groups := make([]BetaGroup, 0, len(rs)) + for _, r := range rs { + groups = append(groups, toBetaGroup(r)) + } + return groups, nil +} + +// MatchBetaGroup picks the group called name, case-insensitively. It returns +// nil when none matches and an error when several do, since acting on the +// wrong one of "Team" and "team" would be silent. +func MatchBetaGroup(groups []BetaGroup, name string) (*BetaGroup, error) { + var matches []BetaGroup + for _, g := range groups { + if strings.EqualFold(g.Name, name) { + matches = append(matches, g) + } + } + switch len(matches) { + case 0: + return nil, nil + case 1: + return &matches[0], nil + } + names := make([]string, 0, len(matches)) + for _, g := range matches { + names = append(names, g.Name+" ("+g.ID+")") + } + return nil, fmt.Errorf("%d TestFlight groups match %s: %s; rename one in App Store Connect first", len(matches), name, strings.Join(names, ", ")) +} + +// BetaGroupSpec describes a TestFlight group to create. +type BetaGroupSpec struct { + AppID string + Name string + Internal bool + // PublicLinkEnabled turns on the public invitation link (external groups only). + PublicLinkEnabled bool + // HasAccessToAllBuilds gives an internal group every build automatically. + HasAccessToAllBuilds bool +} + +// CreateBetaGroup creates a TestFlight group for the app. +func (c *Client) CreateBetaGroup(ctx context.Context, spec BetaGroupSpec) (*BetaGroup, error) { + attrs := betaGroupAttributes{Name: spec.Name, IsInternalGroup: &spec.Internal} + if spec.PublicLinkEnabled { + attrs.PublicLinkEnabled = &spec.PublicLinkEnabled + } + if spec.Internal { + attrs.HasAccessToAllBuilds = &spec.HasAccessToAllBuilds + } + req := Resource[betaGroupAttributes]{ + Type: "betaGroups", + Attributes: attrs, + Relationships: Relationships{"app": ToOne("apps", spec.AppID)}, + } + r, err := post[betaGroupAttributes, betaGroupAttributes](ctx, c, "/v1/betaGroups", req) + if err != nil { + return nil, err + } + g := toBetaGroup(*r) + return &g, nil +} + +// DeleteBetaGroup deletes a TestFlight group; its testers stay on the team. +func (c *Client) DeleteBetaGroup(ctx context.Context, groupID string) error { + return c.Delete(ctx, "/v1/betaGroups/"+groupID, nil) +} + +// AddBetaTestersToGroup puts existing testers into the group. +func (c *Client) AddBetaTestersToGroup(ctx context.Context, groupID string, testerIDs []string) error { + return c.Post(ctx, "/v1/betaGroups/"+groupID+"/relationships/betaTesters", ToMany("betaTesters", testerIDs), nil) +} + +// RemoveBetaTestersFromGroup takes testers out of the group without deleting them. +func (c *Client) RemoveBetaTestersFromGroup(ctx context.Context, groupID string, testerIDs []string) error { + return c.Delete(ctx, "/v1/betaGroups/"+groupID+"/relationships/betaTesters", ToMany("betaTesters", testerIDs)) +} diff --git a/internal/asc/betagroups_test.go b/internal/asc/betagroups_test.go new file mode 100644 index 0000000..f2344a9 --- /dev/null +++ b/internal/asc/betagroups_test.go @@ -0,0 +1,114 @@ +package asc + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestCreateBetaGroupBodies(t *testing.T) { + var body map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/v1/betaGroups" { + t.Errorf("unexpected request %s %s", r.Method, r.URL) + } + _ = json.NewDecoder(r.Body).Decode(&body) + attrs := obj(t, body, "data", "attributes") + attrs["publicLink"] = "https://testflight.apple.com/join/abc" + writeJSON(w, 201, map[string]any{"data": map[string]any{"type": "betaGroups", "id": "g-new", "attributes": attrs}}) + })) + defer srv.Close() + c := newTestClient(t, srv) + ctx := context.Background() + + g, err := c.CreateBetaGroup(ctx, BetaGroupSpec{AppID: "app-1", Name: "Team", Internal: true, HasAccessToAllBuilds: true}) + if err != nil { + t.Fatal(err) + } + if g.ID != "g-new" || g.Name != "Team" || !g.Internal || !g.HasAccessToAllBuilds || g.PublicLinkEnabled { + t.Errorf("group = %+v", g) + } + data := obj(t, body, "data") + attrs := obj(t, data, "attributes") + if attrs["name"] != "Team" || attrs["isInternalGroup"] != true || attrs["hasAccessToAllBuilds"] != true { + t.Errorf("internal attributes = %v", attrs) + } + if _, has := attrs["publicLinkEnabled"]; has { + t.Errorf("publicLinkEnabled must be omitted unless asked: %v", attrs) + } + if obj(t, data, "relationships", "app", "data")["id"] != "app-1" { + t.Errorf("app relationship = %v", data["relationships"]) + } + + g, err = c.CreateBetaGroup(ctx, BetaGroupSpec{AppID: "app-1", Name: "Public", PublicLinkEnabled: true}) + if err != nil { + t.Fatal(err) + } + if g.Internal || !g.PublicLinkEnabled || g.PublicLink != "https://testflight.apple.com/join/abc" { + t.Errorf("group = %+v", g) + } + attrs = obj(t, body, "data", "attributes") + if attrs["isInternalGroup"] != false || attrs["publicLinkEnabled"] != true { + t.Errorf("external attributes = %v", attrs) + } + if _, has := attrs["hasAccessToAllBuilds"]; has { + t.Errorf("hasAccessToAllBuilds is internal-only: %v", attrs) + } +} + +func TestMatchBetaGroup(t *testing.T) { + groups := []BetaGroup{{ID: "g1", Name: "Team", Internal: true}, {ID: "g2", Name: "Beta Testers"}, {ID: "g3", Name: "beta testers"}} + if g, err := MatchBetaGroup(groups, "team"); err != nil || g == nil || g.ID != "g1" { + t.Errorf("case-insensitive match = %+v, err = %v", g, err) + } + if g, err := MatchBetaGroup(groups, "Nightly"); err != nil || g != nil { + t.Errorf("no match = %+v, err = %v", g, err) + } + g, err := MatchBetaGroup(groups, "Beta Testers") + if g != nil || err == nil || !strings.Contains(err.Error(), "Beta Testers (g2)") || !strings.Contains(err.Error(), "beta testers (g3)") { + t.Errorf("duplicates must be refused and listed: %+v, %v", g, err) + } +} + +func TestBetaGroupDeleteAndTesterLinkages(t *testing.T) { + var calls []string + var body map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls = append(calls, r.Method+" "+r.URL.Path) + body = nil + _ = json.NewDecoder(r.Body).Decode(&body) + w.WriteHeader(204) + })) + defer srv.Close() + c := newTestClient(t, srv) + ctx := context.Background() + + if err := c.AddBetaTestersToGroup(ctx, "g1", []string{"t1", "t2"}); err != nil { + t.Fatal(err) + } + links := arr(t, body, "data") + if len(links) != 2 || obj(t, links[0])["type"] != "betaTesters" || obj(t, links[1])["id"] != "t2" { + t.Errorf("add body = %v", body) + } + if err := c.RemoveBetaTestersFromGroup(ctx, "g1", []string{"t1"}); err != nil { + t.Fatal(err) + } + if links = arr(t, body, "data"); len(links) != 1 || obj(t, links[0])["id"] != "t1" { + t.Errorf("remove body = %v", body) + } + if err := c.DeleteBetaGroup(ctx, "g1"); err != nil { + t.Fatal(err) + } + want := []string{"POST /v1/betaGroups/g1/relationships/betaTesters", "DELETE /v1/betaGroups/g1/relationships/betaTesters", "DELETE /v1/betaGroups/g1"} + if len(calls) != len(want) { + t.Fatalf("calls = %v", calls) + } + for i := range want { + if calls[i] != want[i] { + t.Errorf("call %d = %s, want %s", i, calls[i], want[i]) + } + } +} diff --git a/internal/asc/betatesters.go b/internal/asc/betatesters.go new file mode 100644 index 0000000..365c4da --- /dev/null +++ b/internal/asc/betatesters.go @@ -0,0 +1,168 @@ +package asc + +import ( + "context" + "net/http" + "net/url" + "strings" +) + +// Beta tester states. +const ( + BetaTesterNotInvited = "NOT_INVITED" + BetaTesterInvited = "INVITED" + BetaTesterAccepted = "ACCEPTED" + BetaTesterInstalled = "INSTALLED" + BetaTesterRevoked = "REVOKED" +) + +// BetaTester is a TestFlight tester. +type BetaTester struct { + ID string + Email string + FirstName string + LastName string + InviteType string // EMAIL or PUBLIC_LINK + State string +} + +type betaTesterAttributes struct { + FirstName string `json:"firstName,omitempty"` + LastName string `json:"lastName,omitempty"` + Email string `json:"email,omitempty"` + InviteType string `json:"inviteType,omitempty"` + State string `json:"state,omitempty"` +} + +func toBetaTester(r Resource[betaTesterAttributes]) BetaTester { + a := r.Attributes + return BetaTester{ID: r.ID, Email: a.Email, FirstName: a.FirstName, LastName: a.LastName, InviteType: a.InviteType, State: a.State} +} + +// BetaTesterFilter narrows ListBetaTesters. Empty fields are not filtered on. +type BetaTesterFilter struct { + AppID string + GroupID string + Email string +} + +// ListBetaTesters lists testers by email. +func (c *Client) ListBetaTesters(ctx context.Context, f *BetaTesterFilter) ([]BetaTester, error) { + q := url.Values{"sort": {"email"}} + if f.AppID != "" { + q.Set("filter[apps]", f.AppID) + } + if f.GroupID != "" { + q.Set("filter[betaGroups]", f.GroupID) + } + if f.Email != "" { + // App Store Connect stores addresses lowercased. + q.Set("filter[email]", strings.ToLower(f.Email)) + } + rs, err := getAll[betaTesterAttributes](ctx, c, "/v1/betaTesters", q) + if err != nil { + return nil, err + } + testers := make([]BetaTester, 0, len(rs)) + for _, r := range rs { + testers = append(testers, toBetaTester(r)) + } + return testers, nil +} + +// FindBetaTester returns the tester with that email, or nil. The filter is a +// substring match on Apple's side, so the address is compared exactly here. +func (c *Client) FindBetaTester(ctx context.Context, f *BetaTesterFilter) (*BetaTester, error) { + testers, err := c.ListBetaTesters(ctx, f) + if err != nil { + return nil, err + } + for _, t := range testers { + if strings.EqualFold(t.Email, f.Email) { + return &t, nil + } + } + return nil, nil +} + +// GetBetaTester fetches one tester, e.g. for its current state. +func (c *Client) GetBetaTester(ctx context.Context, testerID string) (*BetaTester, error) { + r, err := getOne[betaTesterAttributes](ctx, c, "/v1/betaTesters/"+testerID, nil) + if err != nil { + return nil, err + } + t := toBetaTester(*r) + return &t, nil +} + +// CodeNoInstallableBuilds is the 409 InviteBetaTester answers while none of +// the tester's groups has a build they could install. +const CodeNoInstallableBuilds = "STATE_ERROR.TESTER_INVITE.NO_INSTALLABLE_BUILDS" + +// InviteBetaTester sends, or resends, the app's TestFlight invitation email to +// a tester the app already has. Team members put into an internal group stay +// NOT_INVITED until this is called. +func (c *Client) InviteBetaTester(ctx context.Context, appID, testerID string) error { + req := Resource[struct{}]{ + Type: "betaTesterInvitations", + Relationships: Relationships{"app": ToOne("apps", appID), "betaTester": ToOne("betaTesters", testerID)}, + } + return c.Post(ctx, "/v1/betaTesterInvitations", Document[Resource[struct{}]]{Data: req}, nil) +} + +// BetaTesterSpec describes a tester to invite. +type BetaTesterSpec struct { + Email string + FirstName string + LastName string + // GroupIDs are the TestFlight groups the tester joins; joining sends the invitation. + GroupIDs []string +} + +// CreateBetaTester creates a tester in the given groups. +func (c *Client) CreateBetaTester(ctx context.Context, spec BetaTesterSpec) (*BetaTester, error) { + req := Resource[betaTesterAttributes]{ + Type: "betaTesters", + Attributes: betaTesterAttributes{Email: spec.Email, FirstName: spec.FirstName, LastName: spec.LastName}, + } + if len(spec.GroupIDs) > 0 { + req.Relationships = Relationships{"betaGroups": ToMany("betaGroups", spec.GroupIDs)} + } + r, err := post[betaTesterAttributes, betaTesterAttributes](ctx, c, "/v1/betaTesters", req) + if err != nil { + return nil, err + } + t := toBetaTester(*r) + return &t, nil +} + +// AddBetaTester creates the tester in the groups, or, when the team already +// has a tester with that email (App Store Connect answers 409), adds the +// existing one to them. created reports which happened. +func (c *Client) AddBetaTester(ctx context.Context, spec BetaTesterSpec) (tester *BetaTester, created bool, err error) { + tester, err = c.CreateBetaTester(ctx, spec) + if err == nil { + return tester, true, nil + } + if !IsStatus(err, http.StatusConflict) { + return nil, false, err + } + existing, findErr := c.FindBetaTester(ctx, &BetaTesterFilter{Email: spec.Email}) + if findErr != nil { + return nil, false, findErr + } + if existing == nil { + return nil, false, err + } + for _, id := range spec.GroupIDs { + if err := c.AddBetaTestersToGroup(ctx, id, []string{existing.ID}); err != nil { + return nil, false, err + } + } + return existing, false, nil +} + +// DeleteBetaTester removes the tester from TestFlight for the whole team. +func (c *Client) DeleteBetaTester(ctx context.Context, testerID string) error { + return c.Delete(ctx, "/v1/betaTesters/"+testerID, nil) +} diff --git a/internal/asc/betatesters_test.go b/internal/asc/betatesters_test.go new file mode 100644 index 0000000..cb14183 --- /dev/null +++ b/internal/asc/betatesters_test.go @@ -0,0 +1,173 @@ +package asc + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "testing" +) + +func TestListBetaTestersFilters(t *testing.T) { + var query url.Values + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/betaTesters" { + t.Errorf("unexpected path %s", r.URL.Path) + } + query = r.URL.Query() + writeJSON(w, 200, map[string]any{"data": []map[string]any{ + {"type": "betaTesters", "id": "t1", "attributes": map[string]any{"email": "a@example.com", "firstName": "Ann", "lastName": "Lee", "inviteType": "EMAIL", "state": "INSTALLED"}}, + {"type": "betaTesters", "id": "t2", "attributes": map[string]any{"email": "b@example.com", "state": "NOT_INVITED"}}, + }}) + })) + defer srv.Close() + c := newTestClient(t, srv) + testers, err := c.ListBetaTesters(context.Background(), &BetaTesterFilter{AppID: "app-1", GroupID: "g1", Email: "a@"}) + if err != nil { + t.Fatal(err) + } + if query.Get("filter[apps]") != "app-1" || query.Get("filter[betaGroups]") != "g1" || query.Get("filter[email]") != "a@" || query.Get("sort") != "email" || query.Get("limit") != "200" { + t.Errorf("query = %v", query) + } + if len(testers) != 2 || testers[0].ID != "t1" || testers[0].FirstName != "Ann" || testers[0].State != BetaTesterInstalled || testers[1].State != BetaTesterNotInvited { + t.Errorf("testers = %+v", testers) + } + // FindBetaTester matches the address exactly, since Apple's filter is a + // substring match, and sends it lowercased, since Apple stores it so. + found, err := c.FindBetaTester(context.Background(), &BetaTesterFilter{Email: "B@example.com"}) + if err != nil || found == nil || found.ID != "t2" { + t.Errorf("found = %+v, err = %v", found, err) + } + if query.Get("filter[email]") != "b@example.com" { + t.Errorf("filter[email] = %q, want lowercased", query.Get("filter[email]")) + } + if found, err = c.FindBetaTester(context.Background(), &BetaTesterFilter{Email: "example.com"}); err != nil || found != nil { + t.Errorf("substring must not match: %+v, %v", found, err) + } +} + +// testerServer fakes the tester routes: POST answers 409 for a known address. +func testerServer(t *testing.T, known map[string]string) (*httptest.Server, *[]string, *map[string]any) { + t.Helper() + var calls []string + var body map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls = append(calls, r.Method+" "+r.URL.Path) + body = nil + _ = json.NewDecoder(r.Body).Decode(&body) + switch r.Method + " " + r.URL.Path { + case "POST /v1/betaTesters": + email, _ := obj(t, body, "data", "attributes")["email"].(string) + if _, exists := known[email]; exists { + writeJSON(w, 409, map[string]any{"errors": []map[string]any{{"status": "409", "code": "ENTITY_ERROR.ATTRIBUTE.INVALID.DUPLICATE", "title": "The provided entity includes an attribute with a value that has already been used", "detail": "A beta tester with the email '" + email + "' already exists."}}}) + return + } + writeJSON(w, 201, map[string]any{"data": map[string]any{"type": "betaTesters", "id": "t-new", "attributes": map[string]any{"email": email, "state": "INVITED"}}}) + case "GET /v1/betaTesters": + email := r.URL.Query().Get("filter[email]") + data := []map[string]any{} + // An empty ID marks an address that conflicts but has no record to find. + if id, ok := known[email]; ok && id != "" { + data = append(data, map[string]any{"type": "betaTesters", "id": id, "attributes": map[string]any{"email": email, "state": "ACCEPTED"}}) + } + writeJSON(w, 200, map[string]any{"data": data}) + case "POST /v1/betaGroups/g1/relationships/betaTesters", "DELETE /v1/betaTesters/t-old": + w.WriteHeader(204) + default: + t.Errorf("unexpected request %s %s", r.Method, r.URL) + w.WriteHeader(404) + } + })) + t.Cleanup(srv.Close) + return srv, &calls, &body +} + +func TestCreateBetaTesterBody(t *testing.T) { + srv, _, body := testerServer(t, nil) + c := newTestClient(t, srv) + tester, created, err := c.AddBetaTester(context.Background(), BetaTesterSpec{Email: "new@example.com", FirstName: "New", LastName: "Tester", GroupIDs: []string{"g1"}}) + if err != nil || !created || tester.ID != "t-new" || tester.State != BetaTesterInvited { + t.Fatalf("tester = %+v, created = %v, err = %v", tester, created, err) + } + data := obj(t, *body, "data") + attrs := obj(t, data, "attributes") + if data["type"] != "betaTesters" || attrs["email"] != "new@example.com" || attrs["firstName"] != "New" || attrs["lastName"] != "Tester" { + t.Errorf("body = %v", *body) + } + links := arr(t, data, "relationships", "betaGroups", "data") + if len(links) != 1 || obj(t, links[0])["id"] != "g1" || obj(t, links[0])["type"] != "betaGroups" { + t.Errorf("group relationship = %v", data["relationships"]) + } +} + +func TestAddBetaTesterAlreadyExists(t *testing.T) { + srv, calls, body := testerServer(t, map[string]string{"old@example.com": "t-old"}) + c := newTestClient(t, srv) + tester, created, err := c.AddBetaTester(context.Background(), BetaTesterSpec{Email: "old@example.com", GroupIDs: []string{"g1"}}) + if err != nil || created || tester.ID != "t-old" { + t.Fatalf("tester = %+v, created = %v, err = %v", tester, created, err) + } + want := []string{"POST /v1/betaTesters", "GET /v1/betaTesters", "POST /v1/betaGroups/g1/relationships/betaTesters"} + if len(*calls) != len(want) { + t.Fatalf("calls = %v", *calls) + } + for i := range want { + if (*calls)[i] != want[i] { + t.Errorf("call %d = %s, want %s", i, (*calls)[i], want[i]) + } + } + if links := arr(t, *body, "data"); len(links) != 1 || obj(t, links[0])["id"] != "t-old" { + t.Errorf("linkage body = %v", *body) + } + if err := c.DeleteBetaTester(context.Background(), "t-old"); err != nil { + t.Fatal(err) + } +} + +func TestInviteBetaTesterBody(t *testing.T) { + var body map[string]any + var calls []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls = append(calls, r.Method+" "+r.URL.Path) + switch r.Method + " " + r.URL.Path { + case "POST /v1/betaTesterInvitations": + _ = json.NewDecoder(r.Body).Decode(&body) + writeJSON(w, 201, map[string]any{"data": map[string]any{"type": "betaTesterInvitations", "id": "inv-9"}}) + case "GET /v1/betaTesters/t1": + writeJSON(w, 200, map[string]any{"data": map[string]any{"type": "betaTesters", "id": "t1", "attributes": map[string]any{"email": "a@example.com", "inviteType": "EMAIL", "state": "INVITED"}}}) + default: + t.Errorf("unexpected request %s %s", r.Method, r.URL) + w.WriteHeader(404) + } + })) + defer srv.Close() + c := newTestClient(t, srv) + if err := c.InviteBetaTester(context.Background(), "app-1", "t1"); err != nil { + t.Fatal(err) + } + data := obj(t, body, "data") + if data["type"] != "betaTesterInvitations" { + t.Errorf("type = %v", data["type"]) + } + if _, has := data["attributes"]; has { + t.Errorf("an invitation has no attributes: %v", data) + } + if obj(t, data, "relationships", "app", "data")["id"] != "app-1" || obj(t, data, "relationships", "betaTester", "data")["id"] != "t1" || obj(t, data, "relationships", "betaTester", "data")["type"] != "betaTesters" { + t.Errorf("relationships = %v", data["relationships"]) + } + tester, err := c.GetBetaTester(context.Background(), "t1") + if err != nil || tester.State != BetaTesterInvited || tester.InviteType != "EMAIL" { + t.Errorf("tester = %+v, err = %v", tester, err) + } +} + +func TestAddBetaTesterConflictWithoutMatch(t *testing.T) { + // A 409 for some other reason, with no tester of that address, surfaces as is. + srv, _, _ := testerServer(t, map[string]string{"ghost@example.com": ""}) + c := newTestClient(t, srv) + _, _, err := c.AddBetaTester(context.Background(), BetaTesterSpec{Email: "ghost@example.com", GroupIDs: []string{"g1"}}) + if !IsStatus(err, 409) { + t.Errorf("err = %v, want the 409", err) + } +} diff --git a/internal/asc/builds.go b/internal/asc/builds.go index 6c1451e..e6ad0f4 100644 --- a/internal/asc/builds.go +++ b/internal/asc/builds.go @@ -30,6 +30,10 @@ type Build struct { // UsesNonExemptEncryption is nil while the export compliance question is // unanswered ("Missing Compliance" in TestFlight). UsesNonExemptEncryption *bool + // Version (marketing version) and BetaGroups (TestFlight group names) are + // only filled in when BuildFilter.Details asked for them. + Version string + BetaGroups []string } type buildAttributes struct { @@ -73,11 +77,17 @@ type BuildFilter struct { ExcludeExpired bool // Limit caps the result to the newest N builds; 0 returns every match. Limit int + // Details also fetches each build's marketing version and TestFlight groups. + Details bool } // ListBuilds lists builds, newest first. func (c *Client) ListBuilds(ctx context.Context, f *BuildFilter) ([]Build, error) { q := url.Values{"sort": {"-uploadedDate"}} + if f.Details { + q.Set("include", "preReleaseVersion,betaGroups") + q.Set("limit[betaGroups]", "50") + } if f.AppID != "" { q.Set("filter[app]", f.AppID) } @@ -96,24 +106,51 @@ func (c *Client) ListBuilds(ctx context.Context, f *BuildFilter) ([]Build, error if f.ExcludeExpired { q.Set("filter[expired]", "false") } - var rs []Resource[buildAttributes] - var err error - if f.Limit > 0 { + // One page covers the limit; larger limits fetch everything and cut. + follow := f.Limit <= 0 || f.Limit > pageLimit + if !follow { q.Set("limit", strconv.Itoa(f.Limit)) - rs, err = getPage[buildAttributes](ctx, c, "/v1/builds", q) - } else { - rs, err = getAll[buildAttributes](ctx, c, "/v1/builds", q) } + rs, included, err := collect[buildAttributes](ctx, c, "/v1/builds", q, follow) if err != nil { return nil, err } + if f.Limit > 0 && len(rs) > f.Limit { + rs = rs[:f.Limit] + } builds := make([]Build, 0, len(rs)) for _, r := range rs { - builds = append(builds, toBuild(r)) + b := toBuild(r) + if f.Details { + if pre, ok := r.Relationships.One("preReleaseVersion"); ok { + b.Version = includedAttr(included, "preReleaseVersions", pre.ID, "version") + } + b.BetaGroups = []string{} + for _, g := range r.Relationships.Many("betaGroups") { + name := includedAttr(included, "betaGroups", g.ID, "name") + if name == "" { + name = g.ID + } + b.BetaGroups = append(b.BetaGroups, name) + } + } + builds = append(builds, b) } return builds, nil } +// ExpireBuild removes the build from TestFlight for good. +func (c *Client) ExpireBuild(ctx context.Context, buildID string) (*Build, error) { + expired := true + req := Resource[buildAttributes]{Type: "builds", ID: buildID, Attributes: buildAttributes{Expired: &expired}} + r, err := patch[buildAttributes, buildAttributes](ctx, c, "/v1/builds/"+buildID, req) + if err != nil { + return nil, err + } + b := toBuild(*r) + return &b, nil +} + // GetBuild fetches one build. func (c *Client) GetBuild(ctx context.Context, id string) (*Build, error) { r, err := getOne[buildAttributes](ctx, c, "/v1/builds/"+id, nil) diff --git a/internal/asc/builds_test.go b/internal/asc/builds_test.go new file mode 100644 index 0000000..ec64d64 --- /dev/null +++ b/internal/asc/builds_test.go @@ -0,0 +1,125 @@ +package asc + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "testing" +) + +func TestListBuildsDetailsUsesIncluded(t *testing.T) { + var query url.Values + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + query = r.URL.Query() + writeJSON(w, 200, map[string]any{ + "data": []map[string]any{ + {"type": "builds", "id": "b2", "attributes": map[string]any{"version": "8", "processingState": "VALID", "uploadedDate": "2026-09-16T10:00:00Z", "expired": false}, + "relationships": map[string]any{ + "preReleaseVersion": map[string]any{"data": map[string]string{"type": "preReleaseVersions", "id": "pre-1"}}, + "betaGroups": map[string]any{"data": []map[string]string{{"type": "betaGroups", "id": "g1"}, {"type": "betaGroups", "id": "g-gone"}}}, + }}, + {"type": "builds", "id": "b1", "attributes": map[string]any{"version": "7", "processingState": "VALID", "expired": true}, + "relationships": map[string]any{ + "preReleaseVersion": map[string]any{"data": map[string]string{"type": "preReleaseVersions", "id": "pre-1"}}, + "betaGroups": map[string]any{"data": []any{}}, + }}, + }, + "included": []map[string]any{ + {"type": "preReleaseVersions", "id": "pre-1", "attributes": map[string]any{"version": "2.0.0", "platform": "IOS"}}, + {"type": "betaGroups", "id": "g1", "attributes": map[string]any{"name": "Team", "isInternalGroup": true}}, + }, + }) + })) + defer srv.Close() + builds, err := newTestClient(t, srv).ListBuilds(context.Background(), &BuildFilter{AppID: "app-1", Platform: PlatformIOS, Details: true, Limit: 10}) + if err != nil { + t.Fatal(err) + } + if query.Get("include") != "preReleaseVersion,betaGroups" || query.Get("limit[betaGroups]") != "50" || query.Get("limit") != "10" || query.Get("filter[app]") != "app-1" { + t.Errorf("query = %v", query) + } + if len(builds) != 2 || builds[0].Version != "2.0.0" || builds[0].BuildNumber != "8" || builds[1].Version != "2.0.0" || !builds[1].Expired { + t.Errorf("builds = %+v", builds) + } + if g := builds[0].BetaGroups; len(g) != 2 || g[0] != "Team" || g[1] != "g-gone" { + t.Errorf("groups = %v (unknown included IDs fall back to the ID)", g) + } + if builds[1].BetaGroups == nil || len(builds[1].BetaGroups) != 0 { + t.Errorf("no groups must be an empty list, got %#v", builds[1].BetaGroups) + } +} + +func TestListBuildsLargeLimitFollowsPagesAndCuts(t *testing.T) { + var srv *httptest.Server + srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + if q.Get("limit") != "200" { + t.Errorf("limit = %q, want the page maximum", q.Get("limit")) + } + build := func(id string) map[string]any { + return map[string]any{"type": "builds", "id": id, "attributes": map[string]any{"version": id}} + } + if q.Get("cursor") == "" { + writeJSON(w, 200, map[string]any{"data": []map[string]any{build("1"), build("2")}, "links": map[string]string{"next": srv.URL + "/v1/builds?limit=200&cursor=x"}}) + return + } + writeJSON(w, 200, map[string]any{"data": []map[string]any{build("3"), build("4")}}) + })) + defer srv.Close() + builds, err := newTestClient(t, srv).ListBuilds(context.Background(), &BuildFilter{AppID: "app-1", Limit: 300}) + if err != nil || len(builds) != 4 { + t.Fatalf("builds = %+v, err = %v", builds, err) + } + builds, err = newTestClient(t, srv).ListBuilds(context.Background(), &BuildFilter{AppID: "app-1", Limit: 201}) + if err != nil || len(builds) != 4 { + t.Fatalf("builds = %+v, err = %v", builds, err) + } +} + +func TestExpireBuild(t *testing.T) { + var body map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPatch || r.URL.Path != "/v1/builds/b1" { + t.Errorf("unexpected request %s %s", r.Method, r.URL) + } + _ = json.NewDecoder(r.Body).Decode(&body) + writeJSON(w, 200, map[string]any{"data": map[string]any{"type": "builds", "id": "b1", "attributes": map[string]any{"version": "7", "expired": true, "processingState": "VALID"}}}) + })) + defer srv.Close() + b, err := newTestClient(t, srv).ExpireBuild(context.Background(), "b1") + if err != nil || !b.Expired || b.BuildNumber != "7" { + t.Fatalf("build = %+v, err = %v", b, err) + } + data := obj(t, body, "data") + if data["type"] != "builds" || data["id"] != "b1" || obj(t, data, "attributes")["expired"] != true { + t.Errorf("PATCH body = %v", body) + } + if _, has := obj(t, data, "attributes")["usesNonExemptEncryption"]; has { + t.Errorf("expire must not touch compliance: %v", body) + } +} + +func TestListApps(t *testing.T) { + var srv *httptest.Server + srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + if r.URL.Path != "/v1/apps" || q.Get("sort") != "name" { + t.Errorf("unexpected request %s", r.URL) + } + if q.Get("cursor") == "" { + writeJSON(w, 200, map[string]any{ + "data": []map[string]any{{"type": "apps", "id": "app-1", "attributes": map[string]any{"name": "Alpha", "bundleId": "com.example.alpha", "sku": "ALPHA1"}}}, + "links": map[string]string{"next": srv.URL + "/v1/apps?sort=name&limit=200&cursor=n"}, + }) + return + } + writeJSON(w, 200, map[string]any{"data": []map[string]any{{"type": "apps", "id": "app-2", "attributes": map[string]any{"name": "Beta", "bundleId": "com.example.beta"}}}}) + })) + defer srv.Close() + apps, err := newTestClient(t, srv).ListApps(context.Background()) + if err != nil || len(apps) != 2 || apps[0].SKU != "ALPHA1" || apps[1].BundleID != "com.example.beta" { + t.Errorf("apps = %+v, err = %v", apps, err) + } +} diff --git a/internal/asc/client.go b/internal/asc/client.go index 6607d37..c1416bf 100644 --- a/internal/asc/client.go +++ b/internal/asc/client.go @@ -139,6 +139,21 @@ func IsStatus(err error, status int) bool { return errors.As(err, &e) && e.StatusCode == status } +// HasCode reports whether err is an App Store Connect error carrying the +// given error code (STATE_ERROR.TESTER_INVITE.NO_INSTALLABLE_BUILDS, ...). +func HasCode(err error, code string) bool { + var e *Error + if !errors.As(err, &e) { + return false + } + for _, d := range e.Errors { + if d.Code == code { + return true + } + } + return false +} + // Get performs a GET. path is relative to the base URL ("/v1/apps") or an // absolute URL such as a pagination link; query is appended when non-nil. func (c *Client) Get(ctx context.Context, path string, query url.Values, out any) error { diff --git a/internal/asc/jsonapi.go b/internal/asc/jsonapi.go index fb6d202..d85ea69 100644 --- a/internal/asc/jsonapi.go +++ b/internal/asc/jsonapi.go @@ -13,6 +13,8 @@ type Document[T any] struct { Data T `json:"data"` Links Links `json:"links,omitzero"` Meta *Meta `json:"meta,omitempty"` + // Included carries the related resources an include parameter asked for. + Included []Resource[json.RawMessage] `json:"included,omitempty"` } // Links carries pagination links. @@ -84,6 +86,31 @@ func (r Relationships) One(name string) (Linkage, bool) { return rel.One() } +// Many returns the named to-many linkages; nil when absent or not requested +// (App Store Connect only lists them when the relationship is included). +func (r Relationships) Many(name string) []Linkage { + var linkages []Linkage + if rel, ok := r[name]; ok && len(rel.Data) > 0 { + _ = json.Unmarshal(rel.Data, &linkages) + } + return linkages +} + +// includedAttr returns one string attribute of an included resource by type and ID. +func includedAttr(included []Resource[json.RawMessage], resourceType, id, attr string) string { + for _, r := range included { + if r.Type != resourceType || r.ID != id { + continue + } + var attrs map[string]json.RawMessage + var s string + if json.Unmarshal(r.Attributes, &attrs) == nil && json.Unmarshal(attrs[attr], &s) == nil { + return s + } + } + return "" +} + // pageLimit is the largest page App Store Connect serves. const pageLimit = 200 @@ -97,37 +124,45 @@ func getOne[A any](ctx context.Context, c *Client, path string, query url.Values // getAll fetches a collection, following links.next until exhausted. func getAll[A any](ctx context.Context, c *Client, path string, query url.Values) ([]Resource[A], error) { - if query == nil { - query = url.Values{} - } - if query.Get("limit") == "" { - query.Set("limit", strconv.Itoa(pageLimit)) + rs, _, err := collect[A](ctx, c, path, query, true) + return rs, err +} + +// getPage fetches one page of a collection without following links. +func getPage[A any](ctx context.Context, c *Client, path string, query url.Values) ([]Resource[A], error) { + rs, _, err := collect[A](ctx, c, path, query, false) + return rs, err +} + +// collect fetches a collection and the resources its include parameter pulled +// in, following links.next when follow is set. +func collect[A any](ctx context.Context, c *Client, path string, query url.Values, follow bool) ([]Resource[A], []Resource[json.RawMessage], error) { + if follow { + if query == nil { + query = url.Values{} + } + if query.Get("limit") == "" { + query.Set("limit", strconv.Itoa(pageLimit)) + } } var all []Resource[A] + var included []Resource[json.RawMessage] next := path for { var doc Document[[]Resource[A]] if err := c.Get(ctx, next, query, &doc); err != nil { - return nil, err + return nil, nil, err } all = append(all, doc.Data...) - if doc.Links.Next == "" { - return all, nil + included = append(included, doc.Included...) + if !follow || doc.Links.Next == "" { + return all, included, nil } // The next link already carries the filters and cursor. next, query = doc.Links.Next, nil } } -// getPage fetches one page of a collection without following links. -func getPage[A any](ctx context.Context, c *Client, path string, query url.Values) ([]Resource[A], error) { - var doc Document[[]Resource[A]] - if err := c.Get(ctx, path, query, &doc); err != nil { - return nil, err - } - return doc.Data, nil -} - func post[Req, Resp any](ctx context.Context, c *Client, path string, req Resource[Req]) (*Resource[Resp], error) { var doc Document[Resource[Resp]] if err := c.Post(ctx, path, Document[Resource[Req]]{Data: req}, &doc); err != nil { diff --git a/internal/asc/testflight.go b/internal/asc/testflight.go index aaa2165..f263a07 100644 --- a/internal/asc/testflight.go +++ b/internal/asc/testflight.go @@ -2,45 +2,9 @@ package asc import ( "context" - "net/url" "time" ) -// BetaGroup is a TestFlight tester group. -type BetaGroup struct { - ID string - Name string - Internal bool - PublicLinkEnabled bool -} - -type betaGroupAttributes struct { - Name string `json:"name,omitempty"` - IsInternalGroup *bool `json:"isInternalGroup,omitempty"` - PublicLinkEnabled *bool `json:"publicLinkEnabled,omitempty"` - HasAccessToAllBuilds *bool `json:"hasAccessToAllBuilds,omitempty"` -} - -// ListBetaGroups lists the app's TestFlight groups. -func (c *Client) ListBetaGroups(ctx context.Context, appID string) ([]BetaGroup, error) { - rs, err := getAll[betaGroupAttributes](ctx, c, "/v1/betaGroups", url.Values{"filter[app]": {appID}}) - if err != nil { - return nil, err - } - groups := make([]BetaGroup, 0, len(rs)) - for _, r := range rs { - g := BetaGroup{ID: r.ID, Name: r.Attributes.Name} - if r.Attributes.IsInternalGroup != nil { - g.Internal = *r.Attributes.IsInternalGroup - } - if r.Attributes.PublicLinkEnabled != nil { - g.PublicLinkEnabled = *r.Attributes.PublicLinkEnabled - } - groups = append(groups, g) - } - return groups, nil -} - // BetaBuildLocalization is the "What to Test" text of a build in one locale. type BetaBuildLocalization struct { ID string diff --git a/internal/asc/users.go b/internal/asc/users.go new file mode 100644 index 0000000..2ff2700 --- /dev/null +++ b/internal/asc/users.go @@ -0,0 +1,138 @@ +package asc + +import ( + "context" + "net/url" + "strings" + "time" +) + +// RoleCustomerSupport is the least privileged team role; enough to be an +// internal TestFlight tester of the apps made visible to the user. +const RoleCustomerSupport = "CUSTOMER_SUPPORT" + +// User is a member of the App Store Connect team. +type User struct { + ID string + Email string // ASC calls it "username" + FirstName string + LastName string + Roles []string + AllAppsVisible bool +} + +type userAttributes struct { + Username string `json:"username,omitempty"` + FirstName string `json:"firstName,omitempty"` + LastName string `json:"lastName,omitempty"` + Roles []string `json:"roles,omitempty"` + AllAppsVisible *bool `json:"allAppsVisible,omitempty"` +} + +func toUser(r Resource[userAttributes]) User { + a := r.Attributes + u := User{ID: r.ID, Email: a.Username, FirstName: a.FirstName, LastName: a.LastName, Roles: a.Roles} + if a.AllAppsVisible != nil { + u.AllAppsVisible = *a.AllAppsVisible + } + return u +} + +// ListUsers lists the team's members by email. +func (c *Client) ListUsers(ctx context.Context) ([]User, error) { + rs, err := getAll[userAttributes](ctx, c, "/v1/users", url.Values{"sort": {"username"}}) + if err != nil { + return nil, err + } + users := make([]User, 0, len(rs)) + for _, r := range rs { + users = append(users, toUser(r)) + } + return users, nil +} + +// FindUser returns the team member with that email, or nil. +func (c *Client) FindUser(ctx context.Context, email string) (*User, error) { + rs, err := getAll[userAttributes](ctx, c, "/v1/users", url.Values{"filter[username]": {email}}) + if err != nil { + return nil, err + } + for _, r := range rs { + if strings.EqualFold(r.Attributes.Username, email) { + u := toUser(r) + return &u, nil + } + } + return nil, nil +} + +// UserInvitation is a pending invitation to join the team. +type UserInvitation struct { + ID string + Email string + FirstName string + LastName string + Roles []string + ExpirationDate time.Time +} + +type userInvitationAttributes struct { + Email string `json:"email,omitempty"` + FirstName string `json:"firstName,omitempty"` + LastName string `json:"lastName,omitempty"` + Roles []string `json:"roles,omitempty"` + AllAppsVisible *bool `json:"allAppsVisible,omitempty"` + ExpirationDate *time.Time `json:"expirationDate,omitempty"` +} + +func toUserInvitation(r Resource[userInvitationAttributes]) UserInvitation { + a := r.Attributes + inv := UserInvitation{ID: r.ID, Email: a.Email, FirstName: a.FirstName, LastName: a.LastName, Roles: a.Roles} + if a.ExpirationDate != nil { + inv.ExpirationDate = *a.ExpirationDate + } + return inv +} + +// FindUserInvitation returns the pending team invitation for that email, or nil. +func (c *Client) FindUserInvitation(ctx context.Context, email string) (*UserInvitation, error) { + rs, err := getAll[userInvitationAttributes](ctx, c, "/v1/userInvitations", url.Values{"filter[email]": {email}}) + if err != nil { + return nil, err + } + for _, r := range rs { + if strings.EqualFold(r.Attributes.Email, email) { + inv := toUserInvitation(r) + return &inv, nil + } + } + return nil, nil +} + +// UserInvitationSpec describes a person to invite to the team. +type UserInvitationSpec struct { + Email string + FirstName string + LastName string + Roles []string + // AllAppsVisible grants every app; otherwise only VisibleAppIDs. + AllAppsVisible bool + VisibleAppIDs []string +} + +// InviteUser sends a team invitation; App Store Connect emails the person. +func (c *Client) InviteUser(ctx context.Context, spec *UserInvitationSpec) (*UserInvitation, error) { + req := Resource[userInvitationAttributes]{ + Type: "userInvitations", + Attributes: userInvitationAttributes{Email: spec.Email, FirstName: spec.FirstName, LastName: spec.LastName, Roles: spec.Roles, AllAppsVisible: &spec.AllAppsVisible}, + } + if !spec.AllAppsVisible { + req.Relationships = Relationships{"visibleApps": ToMany("apps", spec.VisibleAppIDs)} + } + r, err := post[userInvitationAttributes, userInvitationAttributes](ctx, c, "/v1/userInvitations", req) + if err != nil { + return nil, err + } + inv := toUserInvitation(*r) + return &inv, nil +} diff --git a/internal/asc/users_test.go b/internal/asc/users_test.go new file mode 100644 index 0000000..8f18dd2 --- /dev/null +++ b/internal/asc/users_test.go @@ -0,0 +1,101 @@ +package asc + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestFindUserMatchesExactly(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/users" || r.URL.Query().Get("filter[username]") == "" { + t.Errorf("unexpected request %s", r.URL) + } + // Apple's filter is a substring match; both come back for "dev@example.com". + writeJSON(w, 200, map[string]any{"data": []map[string]any{ + {"type": "users", "id": "u2", "attributes": map[string]any{"username": "otherdev@example.com", "roles": []string{"DEVELOPER"}}}, + {"type": "users", "id": "u1", "attributes": map[string]any{"username": "Dev@example.com", "firstName": "Dee", "lastName": "Vee", "roles": []string{"APP_MANAGER", "DEVELOPER"}, "allAppsVisible": true}}, + }}) + })) + defer srv.Close() + c := newTestClient(t, srv) + u, err := c.FindUser(context.Background(), "dev@example.com") + if err != nil || u == nil || u.ID != "u1" || u.FirstName != "Dee" || len(u.Roles) != 2 || !u.AllAppsVisible { + t.Errorf("user = %+v, err = %v", u, err) + } + if u, err = c.FindUser(context.Background(), "nobody@example.com"); err != nil || u != nil { + t.Errorf("user = %+v, err = %v", u, err) + } +} + +func TestInviteUserBody(t *testing.T) { + var body map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/v1/userInvitations" { + t.Errorf("unexpected request %s %s", r.Method, r.URL) + } + _ = json.NewDecoder(r.Body).Decode(&body) + attrs := obj(t, body, "data", "attributes") + attrs["expirationDate"] = "2026-10-01T00:00:00Z" + writeJSON(w, 201, map[string]any{"data": map[string]any{"type": "userInvitations", "id": "inv-1", "attributes": attrs}}) + })) + defer srv.Close() + c := newTestClient(t, srv) + inv, err := c.InviteUser(context.Background(), &UserInvitationSpec{Email: "new@example.com", FirstName: "New", LastName: "Person", Roles: []string{RoleCustomerSupport}, VisibleAppIDs: []string{"app-1"}}) + if err != nil { + t.Fatal(err) + } + if inv.ID != "inv-1" || inv.Email != "new@example.com" || inv.ExpirationDate.IsZero() || len(inv.Roles) != 1 { + t.Errorf("invitation = %+v", inv) + } + data := obj(t, body, "data") + attrs := obj(t, data, "attributes") + if data["type"] != "userInvitations" || attrs["email"] != "new@example.com" || attrs["firstName"] != "New" || attrs["lastName"] != "Person" || attrs["allAppsVisible"] != false { + t.Errorf("attributes = %v", attrs) + } + if roles := arr(t, attrs, "roles"); len(roles) != 1 || roles[0] != "CUSTOMER_SUPPORT" { + t.Errorf("roles = %v", roles) + } + if apps := arr(t, data, "relationships", "visibleApps", "data"); len(apps) != 1 || obj(t, apps[0])["id"] != "app-1" || obj(t, apps[0])["type"] != "apps" { + t.Errorf("visibleApps = %v", data["relationships"]) + } + + if _, err := c.InviteUser(context.Background(), &UserInvitationSpec{Email: "admin@example.com", FirstName: "A", LastName: "D", Roles: []string{"ADMIN"}, AllAppsVisible: true}); err != nil { + t.Fatal(err) + } + if _, has := obj(t, body, "data")["relationships"]; has || obj(t, body, "data", "attributes")["allAppsVisible"] != true { + t.Errorf("all-apps invitation must carry no visibleApps: %v", body) + } +} + +func TestFindUserInvitation(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/userInvitations" || r.URL.Query().Get("filter[email]") != "new@example.com" { + t.Errorf("unexpected request %s", r.URL) + } + writeJSON(w, 200, map[string]any{"data": []map[string]any{ + {"type": "userInvitations", "id": "inv-1", "attributes": map[string]any{"email": "new@example.com", "roles": []string{"CUSTOMER_SUPPORT"}, "expirationDate": "2026-10-01T00:00:00Z"}}, + }}) + })) + defer srv.Close() + inv, err := newTestClient(t, srv).FindUserInvitation(context.Background(), "new@example.com") + if err != nil || inv == nil || inv.ID != "inv-1" || inv.ExpirationDate.Year() != 2026 { + t.Errorf("invitation = %+v, err = %v", inv, err) + } +} + +func TestListUsers(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/users" || r.URL.Query().Get("sort") != "username" { + t.Errorf("unexpected request %s", r.URL) + } + writeJSON(w, 200, map[string]any{"data": []map[string]any{{"type": "users", "id": "u1", "attributes": map[string]any{"username": "a@example.com", "roles": []string{"ADMIN"}}}}}) + })) + defer srv.Close() + users, err := newTestClient(t, srv).ListUsers(context.Background()) + if err != nil || len(users) != 1 || users[0].Email != "a@example.com" || users[0].Roles[0] != "ADMIN" { + t.Errorf("users = %+v, err = %v", users, err) + } +} diff --git a/internal/config/types.go b/internal/config/types.go index d67914d..38b5188 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -110,6 +110,7 @@ type IOSConfig struct { // Empty means root directory contains the Xcode project Path string `json:"path,omitempty"` Scheme string `json:"scheme,omitempty"` // Xcode scheme to build (auto-detected if empty) + BundleID string `json:"bundleId,omitempty"` // App Store Connect app for the asc commands (default: read from dist/*.ipa) Signing bool `json:"signing,omitempty"` // Whether code signing is configured Configuration string `json:"configuration,omitempty"` // Build configuration: Debug (faster) or Release (production) } diff --git a/internal/distribute/distribute_test.go b/internal/distribute/distribute_test.go index 8b1597e..05bc1de 100644 --- a/internal/distribute/distribute_test.go +++ b/internal/distribute/distribute_test.go @@ -10,6 +10,7 @@ import ( "crypto/x509" "encoding/json" "encoding/pem" + "fmt" "io" "net/http" "net/http/httptest" @@ -68,10 +69,30 @@ type fake struct { openSubmission bool submitStatus int betaReviewExists bool + // autoGroup adds an internal group with automatic distribution, dupGroup a + // second "beta testers"; created collects groups made through the API. + noGroups bool + autoGroup bool + dupGroup bool + created []map[string]any + // testerStates defaults to ACCEPTED, flipping to INVITED once an + // invitation is posted; noBuilds makes those invitations fail instead. + users map[string]bool + testers map[string]string + testerStates map[string]string + pendingInvite bool + noBuilds bool } func newFake(t *testing.T) *fake { - f := &fake{t: t, bodies: map[string]map[string]any{}, buildState: "VALID", versionState: "PREPARE_FOR_SUBMISSION", submitStatus: 200} + f := &fake{t: t, bodies: map[string]map[string]any{}, buildState: "VALID", versionState: "PREPARE_FOR_SUBMISSION", submitStatus: 200, users: map[string]bool{}, testers: map[string]string{}, testerStates: map[string]string{}} + tester := func(email, id string) map[string]any { + state := f.testerStates[id] + if state == "" { + state = "ACCEPTED" + } + return map[string]any{"type": "betaTesters", "id": id, "attributes": map[string]any{"email": email, "inviteType": "EMAIL", "state": state}} + } mux := http.NewServeMux() res := func(typ, id string, attrs map[string]any, rels map[string]any) map[string]any { r := map[string]any{"type": typ, "id": id, "attributes": attrs} @@ -159,9 +180,96 @@ func newFake(t *testing.T) *fake { one(w, 200, build()) })) mux.HandleFunc("GET /v1/betaGroups", wrap(func(w http.ResponseWriter, r *http.Request) { - many(w, - res("betaGroups", "g-int", map[string]any{"name": "Team", "isInternalGroup": true}, nil), - res("betaGroups", "g-ext", map[string]any{"name": "Beta Testers", "isInternalGroup": false, "publicLinkEnabled": true}, nil)) + if f.noGroups { + many(w) + return + } + groups := []any{ + res("betaGroups", "g-int", map[string]any{"name": "Team", "isInternalGroup": true, "hasAccessToAllBuilds": false}, nil), + res("betaGroups", "g-ext", map[string]any{"name": "Beta Testers", "isInternalGroup": false, "publicLinkEnabled": true}, nil), + } + if f.autoGroup { + groups = append(groups, res("betaGroups", "g-auto", map[string]any{"name": "Everyone", "isInternalGroup": true, "hasAccessToAllBuilds": true}, nil)) + } + if f.dupGroup { + groups = append(groups, res("betaGroups", "g-dup", map[string]any{"name": "beta testers", "isInternalGroup": false, "publicLinkEnabled": nil}, nil)) + } + for _, g := range f.created { + groups = append(groups, g) + } + many(w, groups...) + })) + mux.HandleFunc("POST /v1/betaGroups", wrap(func(w http.ResponseWriter, r *http.Request) { + attrs := obj(f.t, f.bodies["POST /v1/betaGroups"], "data", "attributes") + g := res("betaGroups", fmt.Sprintf("g-new-%d", len(f.created)+1), attrs, nil) + f.created = append(f.created, g) + one(w, 201, g) + })) + mux.HandleFunc("POST /v1/betaGroups/{id}/relationships/betaTesters", wrap(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(204) })) + mux.HandleFunc("GET /v1/betaTesters", wrap(func(w http.ResponseWriter, r *http.Request) { + email := r.URL.Query().Get("filter[email]") + if email != strings.ToLower(email) { + f.t.Errorf("filter[email] must be lowercased: %q", email) + } + var testers []any + for e, id := range f.testers { + if email == "" || strings.EqualFold(e, email) { + testers = append(testers, tester(e, id)) + } + } + many(w, testers...) + })) + mux.HandleFunc("GET /v1/betaTesters/{id}", wrap(func(w http.ResponseWriter, r *http.Request) { + for e, id := range f.testers { + if id == r.PathValue("id") { + one(w, 200, tester(e, id)) + return + } + } + w.WriteHeader(404) + })) + mux.HandleFunc("POST /v1/betaTesterInvitations", wrap(func(w http.ResponseWriter, r *http.Request) { + if f.noBuilds { + writeJSON(w, 409, map[string]any{"errors": []map[string]any{{"status": "409", "code": "STATE_ERROR.TESTER_INVITE.NO_INSTALLABLE_BUILDS", "title": "The request cannot be fulfilled because of the state of another resource.", "detail": "The tester has no installable builds."}}}) + return + } + id, _ := obj(f.t, f.bodies["POST /v1/betaTesterInvitations"], "data", "relationships", "betaTester", "data")["id"].(string) + f.testerStates[id] = "INVITED" + one(w, 201, res("betaTesterInvitations", "bti-1", nil, nil)) + })) + mux.HandleFunc("POST /v1/betaTesters", wrap(func(w http.ResponseWriter, r *http.Request) { + email, _ := obj(f.t, f.bodies["POST /v1/betaTesters"], "data", "attributes")["email"].(string) + if _, exists := f.testers[email]; exists { + writeJSON(w, 409, map[string]any{"errors": []map[string]any{{"status": "409", "code": "ENTITY_ERROR.ATTRIBUTE.INVALID.DUPLICATE", "title": "duplicate", "detail": "A beta tester with the email '" + email + "' already exists."}}}) + return + } + id := fmt.Sprintf("t-new-%d", len(f.testers)+1) + f.testers[email] = id + state := "INVITED" + if f.noBuilds { + state = "NOT_INVITED" + } + one(w, 201, res("betaTesters", id, map[string]any{"email": email, "state": state}, nil)) + })) + mux.HandleFunc("GET /v1/users", wrap(func(w http.ResponseWriter, r *http.Request) { + email := r.URL.Query().Get("filter[username]") + if f.users[email] { + many(w, res("users", "u-"+email, map[string]any{"username": email, "firstName": "Team", "lastName": "Member", "roles": []string{"DEVELOPER"}}, nil)) + return + } + many(w) + })) + mux.HandleFunc("GET /v1/userInvitations", wrap(func(w http.ResponseWriter, r *http.Request) { + email := r.URL.Query().Get("filter[email]") + if f.pendingInvite { + many(w, res("userInvitations", "inv-0", map[string]any{"email": email, "roles": []string{"CUSTOMER_SUPPORT"}}, nil)) + return + } + many(w) + })) + mux.HandleFunc("POST /v1/userInvitations", wrap(func(w http.ResponseWriter, r *http.Request) { + attrs := obj(f.t, f.bodies["POST /v1/userInvitations"], "data", "attributes") + one(w, 201, res("userInvitations", "inv-1", attrs, nil)) })) mux.HandleFunc("GET /v1/builds/{id}/betaBuildLocalizations", wrap(func(w http.ResponseWriter, r *http.Request) { many(w, res("betaBuildLocalizations", "loc-en", map[string]any{"locale": "en-US", "whatsNew": "old"}, nil)) @@ -365,6 +473,22 @@ func TestUploadUndeclaredEncryptionStaysPending(t *testing.T) { } } +func TestProgressSize(t *testing.T) { + for _, tc := range []struct { + sent, total int64 + want string + }{ + {0, 200 << 10, "0/200 KB"}, + {200 << 10, 200 << 10, "200/200 KB"}, + {1 << 20, 3<<20 + 1<<19, "1.0/3.5 MB"}, + {0, 24 << 20, "0.0/24.0 MB"}, + } { + if got := progressSize(tc.sent, tc.total); got != tc.want { + t.Errorf("progressSize(%d, %d) = %q, want %q", tc.sent, tc.total, got, tc.want) + } + } +} + func TestUploadUnknownApp(t *testing.T) { f := newFake(t) _, err := Upload(context.Background(), f.client(t), &UploadOptions{IPAPath: writeIPA(t, strings.ReplaceAll(plistExempt, "com.example.app", "com.other"))}) diff --git a/internal/distribute/submit_test.go b/internal/distribute/submit_test.go index 2a4f740..8fa3d0b 100644 --- a/internal/distribute/submit_test.go +++ b/internal/distribute/submit_test.go @@ -68,29 +68,113 @@ func TestSubmitTestFlightUpdatesExistingNotesAndSkipsReviewForInternal(t *testin func TestSubmitTestFlightListsGroupsWithoutGroupFlag(t *testing.T) { f := newFake(t) - res, err := SubmitTestFlight(context.Background(), f.client(t), &TestFlightOptions{BundleID: "com.example.app"}) + var log bytes.Buffer + res, err := SubmitTestFlight(context.Background(), f.client(t), &TestFlightOptions{BundleID: "com.example.app", Log: &log}) if err != nil { t.Fatal(err) } if len(res.AvailableGroups) != 2 || len(res.Groups) != 0 || f.called("POST /v1/builds/build-9/relationships/betaGroups") { t.Errorf("result = %+v", res) } + if !strings.Contains(log.String(), "Available groups:\n Team (internal)\n Beta Testers (external)") { + t.Errorf("log = %q", log.String()) + } + f.noGroups = true + log.Reset() + if _, err := SubmitTestFlight(context.Background(), f.client(t), &TestFlightOptions{BundleID: "com.example.app", Log: &log}); err != nil { + t.Fatal(err) + } + if !strings.Contains(log.String(), "Available groups: (none)") { + t.Errorf("log = %q", log.String()) + } +} + +func TestSubmitTestFlightCreatesMissingGroup(t *testing.T) { + f := newFake(t) + var log bytes.Buffer + res, err := SubmitTestFlight(context.Background(), f.client(t), &TestFlightOptions{BundleID: "com.example.app", Groups: []string{"Nightly", "team"}, NoEncryption: true, Log: &log}) + if err != nil { + t.Fatalf("%v\n%s", err, log.String()) + } + if len(res.Groups) != 2 || !res.Groups[0].Created || !res.Groups[0].Internal || res.Groups[0].ID != "g-new-1" || res.Groups[1].Created || res.Groups[1].ID != "g-int" { + t.Errorf("groups = %+v", res.Groups) + } + create := obj(t, f.body("POST /v1/betaGroups"), "data") + attrs := obj(t, create, "attributes") + if attrs["name"] != "Nightly" || attrs["isInternalGroup"] != true || attrs["hasAccessToAllBuilds"] != false || obj(t, create, "relationships", "app", "data")["id"] != "app-1" { + t.Errorf("create body = %v", create) + } + if !strings.Contains(log.String(), "Created TestFlight group Nightly (internal)") { + t.Errorf("log = %q", log.String()) + } + links := arr(t, f.body("POST /v1/builds/build-9/relationships/betaGroups"), "data") + if len(links) != 2 || obj(t, links[0])["id"] != "g-new-1" || obj(t, links[1])["id"] != "g-int" { + t.Errorf("linkage = %v", links) + } + if f.called("POST /v1/betaAppReviewSubmissions") { + t.Error("internal groups need no beta review") + } + + // --external creates an external group, which goes through beta review. + f = newFake(t) + res, err = SubmitTestFlight(context.Background(), f.client(t), &TestFlightOptions{BundleID: "com.example.app", Groups: []string{"Public"}, External: true, NoEncryption: true}) + if err != nil { + t.Fatal(err) + } + attrs = obj(t, f.body("POST /v1/betaGroups"), "data", "attributes") + if attrs["isInternalGroup"] != false || res.Groups[0].Internal || res.BetaReview == nil || !f.called("POST /v1/betaAppReviewSubmissions") { + t.Errorf("attrs = %v, result = %+v", attrs, res) + } + if _, has := attrs["hasAccessToAllBuilds"]; has { + t.Errorf("external groups take no hasAccessToAllBuilds: %v", attrs) + } +} + +func TestSubmitTestFlightSkipsAutomaticDistributionGroups(t *testing.T) { + f := newFake(t) + f.autoGroup = true + var log bytes.Buffer + res, err := SubmitTestFlight(context.Background(), f.client(t), &TestFlightOptions{BundleID: "com.example.app", Groups: []string{"Everyone"}, NoEncryption: true, Log: &log}) + if err != nil { + t.Fatalf("%v\n%s", err, log.String()) + } + if len(res.Groups) != 1 || !res.Groups[0].AutoBuilds || f.called("POST /v1/builds/build-9/relationships/betaGroups") { + t.Errorf("result = %+v, calls = %v (adding to such a group is a 422)", res, f.calls) + } + if !strings.Contains(log.String(), "Everyone is an internal group with automatic distribution: every processed build is already available to its testers") { + t.Errorf("log = %q", log.String()) + } + + // Mixed with a manual group, only the manual one is linked and reported. + f = newFake(t) + f.autoGroup = true + log.Reset() + res, err = SubmitTestFlight(context.Background(), f.client(t), &TestFlightOptions{BundleID: "com.example.app", Groups: []string{"Everyone", "Team"}, NoEncryption: true, Log: &log}) + if err != nil { + t.Fatal(err) + } + links := arr(t, f.body("POST /v1/builds/build-9/relationships/betaGroups"), "data") + if len(links) != 1 || obj(t, links[0])["id"] != "g-int" || len(res.Groups) != 2 || res.Groups[1].AutoBuilds { + t.Errorf("linkage = %v, groups = %+v", links, res.Groups) + } + if !strings.Contains(log.String(), "Added build 7 to Team\n") { + t.Errorf("log names the skipped group as added: %q", log.String()) + } } func TestSubmitTestFlightErrors(t *testing.T) { f := newFake(t) c := f.client(t) - _, err := SubmitTestFlight(context.Background(), c, &TestFlightOptions{BundleID: "com.example.app", Groups: []string{"Nobody"}, NoEncryption: true}) - if err == nil || !strings.Contains(err.Error(), "Nobody") || !strings.Contains(err.Error(), "Beta Testers") { - t.Errorf("unknown group: %v", err) - } - f.mu.Lock() - f.buildEncryption = nil // the call above answered it - f.mu.Unlock() - _, err = SubmitTestFlight(context.Background(), c, &TestFlightOptions{BundleID: "com.example.app", Groups: []string{"Team"}}) + _, err := SubmitTestFlight(context.Background(), c, &TestFlightOptions{BundleID: "com.example.app", Groups: []string{"Team"}}) if err == nil || !strings.Contains(err.Error(), "export compliance") { t.Errorf("missing compliance: %v", err) } + f.dupGroup = true + _, err = SubmitTestFlight(context.Background(), c, &TestFlightOptions{BundleID: "com.example.app", Groups: []string{"BETA TESTERS"}, NoEncryption: true}) + if err == nil || !strings.Contains(err.Error(), "2 TestFlight groups match BETA TESTERS") || f.called("POST /v1/betaGroups") || f.called("POST /v1/builds/build-9/relationships/betaGroups") { + t.Errorf("an ambiguous group name must neither create nor add: %v, calls = %v", err, f.calls) + } + f.dupGroup = false f.buildState = "PROCESSING" _, err = SubmitTestFlight(context.Background(), c, &TestFlightOptions{BundleID: "com.example.app", BuildNumber: "7"}) if err == nil || !strings.Contains(err.Error(), "PROCESSING") { diff --git a/internal/distribute/testers.go b/internal/distribute/testers.go new file mode 100644 index 0000000..791096e --- /dev/null +++ b/internal/distribute/testers.go @@ -0,0 +1,171 @@ +package distribute + +import ( + "context" + "fmt" + "io" + + "github.com/MobAI-App/ios-builder/internal/asc" +) + +// Tester statuses reported by AddTester. +const ( + // TesterInvited: a tester record was created in the group, which sends the TestFlight invitation. + TesterInvited = "invited" + // TesterAdded: the existing tester record was added to the group. + TesterAdded = "added" + // TesterTeamInviteSent: the person is not on the team; a team invitation was sent. + TesterTeamInviteSent = "team_invite_sent" + // TesterTeamInvitePending: the person already has an unaccepted team invitation. + TesterTeamInvitePending = "team_invite_pending" +) + +// TesterOptions describes one person to put into a TestFlight group. +type TesterOptions struct { + AppID string + Group asc.BetaGroup + Email string + FirstName string + LastName string + // TeamRole is the role a person not yet on the team is invited with; + // internal groups only take team members. Empty means CUSTOMER_SUPPORT. + TeamRole string + Log io.Writer +} + +// TesterResult is what AddTester reports for one person. +type TesterResult struct { + ID string `json:"id,omitempty"` + Email string `json:"email"` + Group string `json:"group"` + Status string `json:"status"` + // State is the tester record's TestFlight state afterwards (INVITED, + // ACCEPTED, ...); empty when only a team invitation went out. + State string `json:"state,omitempty"` +} + +// AddTester puts a person into a TestFlight group, creating or reusing their +// tester record. Internal groups take team members only, so a stranger is +// invited to the team first and reaches the build once they accept and +// AddTester runs again. +func AddTester(ctx context.Context, client *asc.Client, opts *TesterOptions) (*TesterResult, error) { + g := opts.Group + res := &TesterResult{Email: opts.Email, Group: g.Name} + if !g.Internal { + tester, created, err := client.AddBetaTester(ctx, asc.BetaTesterSpec{Email: opts.Email, FirstName: opts.FirstName, LastName: opts.LastName, GroupIDs: []string{g.ID}}) + if err != nil { + return nil, err + } + return finishTester(ctx, client, opts, res, tester, created) + } + + user, err := client.FindUser(ctx, opts.Email) + if err != nil { + return nil, err + } + if user == nil { + return inviteToTeam(ctx, client, opts, res) + } + tester, err := client.FindBetaTester(ctx, &asc.BetaTesterFilter{Email: opts.Email}) + if err != nil { + return nil, err + } + if tester == nil { + tester, _, err = client.AddBetaTester(ctx, asc.BetaTesterSpec{Email: opts.Email, FirstName: user.FirstName, LastName: user.LastName, GroupIDs: []string{g.ID}}) + if err != nil { + return nil, fmt.Errorf("%s is on the team but has no TestFlight tester record and App Store Connect refused to create one: %w; enable TestFlight for them under Users and Access", opts.Email, err) + } + return finishTester(ctx, client, opts, res, tester, true) + } + if err := client.AddBetaTestersToGroup(ctx, g.ID, []string{tester.ID}); err != nil { + return nil, err + } + return finishTester(ctx, client, opts, res, tester, false) +} + +// finishTester records the outcome and makes sure an email went out: a +// created record is INVITED by App Store Connect itself, but a record added +// to an internal group stays NOT_INVITED until an invitation is sent. +func finishTester(ctx context.Context, client *asc.Client, opts *TesterOptions, res *TesterResult, tester *asc.BetaTester, created bool) (*TesterResult, error) { + res.Status = TesterInvited + if !created { + res.Status = TesterAdded + logf(opts.Log, "Added existing tester %s to %s", tester.Email, res.Group) + // The group add itself may have moved the state; read it back. + updated, err := client.GetBetaTester(ctx, tester.ID) + if err != nil { + return nil, err + } + tester = updated + } + if tester.State != asc.BetaTesterNotInvited { + if created { + logf(opts.Log, "Invited %s to %s", tester.Email, res.Group) + } + } else if invited, err := InviteTester(ctx, client, opts.Log, opts.AppID, tester); asc.HasCode(err, asc.CodeNoInstallableBuilds) { + // A group without a build: App Store Connect keeps the record + // NOT_INVITED and sends the email itself once a build is added. + res.Status = TesterAdded + logf(opts.Log, "Added %s to %s (invite goes out once the group has a build)", tester.Email, res.Group) + } else if err != nil { + return nil, err + } else { + tester = invited + } + res.ID, res.Email, res.State = tester.ID, tester.Email, tester.State + return res, nil +} + +// InviteTester sends, or resends, the app's TestFlight invitation to a tester +// and returns the record with its new state. +func InviteTester(ctx context.Context, client *asc.Client, log io.Writer, appID string, tester *asc.BetaTester) (*asc.BetaTester, error) { + if err := client.InviteBetaTester(ctx, appID, tester.ID); err != nil { + if asc.HasCode(err, asc.CodeNoInstallableBuilds) { + return nil, &noBuildError{email: tester.Email, err: err} + } + return nil, fmt.Errorf("invite %s: %w", tester.Email, err) + } + updated, err := client.GetBetaTester(ctx, tester.ID) + if err != nil { + return nil, err + } + logf(log, "Sent TestFlight invitation to %s (%s)", updated.Email, updated.State) + return updated, nil +} + +// noBuildError is App Store Connect's refusal to invite a tester whose groups +// have no build, said in terms of what to do; the ASC error stays unwrappable. +type noBuildError struct { + email string + err error +} + +func (e *noBuildError) Error() string { + return fmt.Sprintf("%s has no installable build yet: add one to the group first (builder asc groups add-build ); external groups also need the build to pass Beta App Review", e.email) +} + +func (e *noBuildError) Unwrap() error { return e.err } + +func inviteToTeam(ctx context.Context, client *asc.Client, opts *TesterOptions, res *TesterResult) (*TesterResult, error) { + if inv, err := client.FindUserInvitation(ctx, opts.Email); err != nil { + return nil, err + } else if inv != nil { + res.ID, res.Status = inv.ID, TesterTeamInvitePending + logf(opts.Log, "%s already has a pending team invitation; once they accept the email, rerun to add them to %s", opts.Email, res.Group) + return res, nil + } + if opts.FirstName == "" || opts.LastName == "" { + return nil, fmt.Errorf("%s is not on the App Store Connect team, which an internal group requires; pass --first and --last to invite them", opts.Email) + } + role := opts.TeamRole + if role == "" { + role = asc.RoleCustomerSupport + } + inv, err := client.InviteUser(ctx, &asc.UserInvitationSpec{Email: opts.Email, FirstName: opts.FirstName, LastName: opts.LastName, Roles: []string{role}, VisibleAppIDs: []string{opts.AppID}}) + if err != nil { + return nil, fmt.Errorf("invite %s to the team: %w", opts.Email, err) + } + res.ID, res.Status = inv.ID, TesterTeamInviteSent + logf(opts.Log, "Invited %s to the team as %s; they must accept the email, then rerun to add them to %s", opts.Email, role, res.Group) + return res, nil +} diff --git a/internal/distribute/testers_test.go b/internal/distribute/testers_test.go new file mode 100644 index 0000000..b0c9cd9 --- /dev/null +++ b/internal/distribute/testers_test.go @@ -0,0 +1,168 @@ +package distribute + +import ( + "bytes" + "context" + "strings" + "testing" + + "github.com/MobAI-App/ios-builder/internal/asc" +) + +var ( + externalGroup = asc.BetaGroup{ID: "g-ext", Name: "Beta Testers"} + internalGroup = asc.BetaGroup{ID: "g-int", Name: "Team", Internal: true} +) + +func TestAddTesterExternalGroup(t *testing.T) { + f := newFake(t) + f.testers["old@example.com"] = "t-old" + var log bytes.Buffer + c := f.client(t) + + res, err := AddTester(context.Background(), c, &TesterOptions{AppID: "app-1", Group: externalGroup, Email: "new@example.com", FirstName: "New", LastName: "One", Log: &log}) + if err != nil || res.Status != TesterInvited || res.ID == "" || res.Group != "Beta Testers" { + t.Fatalf("result = %+v, err = %v", res, err) + } + body := obj(t, f.body("POST /v1/betaTesters"), "data") + if obj(t, body, "attributes")["firstName"] != "New" || obj(t, arr(t, body, "relationships", "betaGroups", "data")[0])["id"] != "g-ext" { + t.Errorf("create body = %v", body) + } + + // The team already has the address: 409, then the existing record joins the group. + res, err = AddTester(context.Background(), c, &TesterOptions{AppID: "app-1", Group: externalGroup, Email: "old@example.com", Log: &log}) + if err != nil || res.Status != TesterAdded || res.ID != "t-old" || res.State != "ACCEPTED" { + t.Fatalf("result = %+v, err = %v", res, err) + } + if f.called("POST /v1/betaTesterInvitations") { + t.Errorf("an accepted tester needs no invitation: %v", f.calls) + } + if links := arr(t, f.body("POST /v1/betaGroups/g-ext/relationships/betaTesters"), "data"); len(links) != 1 || obj(t, links[0])["id"] != "t-old" { + t.Errorf("linkage = %v", links) + } + if f.called("GET /v1/users") || f.called("POST /v1/userInvitations") { + t.Errorf("external groups never touch the team: %v", f.calls) + } + if !strings.Contains(log.String(), "Invited new@example.com to Beta Testers") || !strings.Contains(log.String(), "Added existing tester old@example.com to Beta Testers") { + t.Errorf("log = %q", log.String()) + } +} + +func TestAddTesterGroupWithoutBuild(t *testing.T) { + // A group with no build keeps the record NOT_INVITED and App Store Connect + // refuses the email, which is an "added", not an error. + f := newFake(t) + f.noBuilds = true + var log bytes.Buffer + c := f.client(t) + res, err := AddTester(context.Background(), c, &TesterOptions{AppID: "app-1", Group: externalGroup, Email: "new@example.com", Log: &log}) + if err != nil || res.Status != TesterAdded || res.ID != "t-new-1" || res.State != "NOT_INVITED" { + t.Fatalf("result = %+v, err = %v", res, err) + } + if !f.called("POST /v1/betaTesterInvitations") { + t.Errorf("the invitation must be attempted: %v", f.calls) + } + if !strings.Contains(log.String(), "Added new@example.com to Beta Testers (invite goes out once the group has a build)") || strings.Contains(log.String(), "Invited new@example.com") { + t.Errorf("log = %q", log.String()) + } + + // Sending the invitation on demand is an error that says what to do. + _, err = InviteTester(context.Background(), c, &log, "app-1", &asc.BetaTester{ID: "t-new-1", Email: "new@example.com", State: "NOT_INVITED"}) + if err == nil || !strings.Contains(err.Error(), "new@example.com has no installable build yet: add one to the group first (builder asc groups add-build ); external groups also need the build to pass Beta App Review") { + t.Errorf("err = %v", err) + } +} + +func TestAddTesterInternalGroupMember(t *testing.T) { + f := newFake(t) + f.users["dev@example.com"] = true + f.testers["dev@example.com"] = "t-dev" + res, err := AddTester(context.Background(), f.client(t), &TesterOptions{AppID: "app-1", Group: internalGroup, Email: "dev@example.com"}) + if err != nil || res.Status != TesterAdded || res.ID != "t-dev" { + t.Fatalf("result = %+v, err = %v", res, err) + } + if links := arr(t, f.body("POST /v1/betaGroups/g-int/relationships/betaTesters"), "data"); len(links) != 1 || obj(t, links[0])["id"] != "t-dev" { + t.Errorf("linkage = %v", links) + } + if f.called("POST /v1/betaTesters") || f.called("POST /v1/userInvitations") || f.called("GET /v1/userInvitations") || f.called("POST /v1/betaTesterInvitations") { + t.Errorf("a member with an accepted tester record needs neither a new record nor an invitation: %v", f.calls) + } + + // A member whose record is still NOT_INVITED (added in the UI, never + // emailed) gets the TestFlight invitation sent after the group add. + f = newFake(t) + f.users["dev@example.com"] = true + f.testers["dev@example.com"] = "t-dev" + f.testerStates["t-dev"] = "NOT_INVITED" + var log bytes.Buffer + res, err = AddTester(context.Background(), f.client(t), &TesterOptions{AppID: "app-1", Group: internalGroup, Email: "dev@example.com", Log: &log}) + if err != nil || res.Status != TesterAdded || res.ID != "t-dev" || res.State != "INVITED" { + t.Fatalf("result = %+v, err = %v", res, err) + } + invite := obj(t, f.body("POST /v1/betaTesterInvitations"), "data") + if obj(t, invite, "relationships", "app", "data")["id"] != "app-1" || obj(t, invite, "relationships", "betaTester", "data")["id"] != "t-dev" { + t.Errorf("invitation = %v", invite) + } + if !strings.Contains(log.String(), "Sent TestFlight invitation to dev@example.com (INVITED)") { + t.Errorf("log = %q", log.String()) + } + + // A member without a tester record gets one created in the group. + f = newFake(t) + f.users["fresh@example.com"] = true + res, err = AddTester(context.Background(), f.client(t), &TesterOptions{AppID: "app-1", Group: internalGroup, Email: "fresh@example.com"}) + if err != nil || res.Status != TesterInvited || res.ID != "t-new-1" || res.State != "INVITED" { + t.Fatalf("result = %+v, err = %v", res, err) + } + if f.called("POST /v1/betaTesterInvitations") { + t.Errorf("creating the record already sends the email: %v", f.calls) + } + attrs := obj(t, f.body("POST /v1/betaTesters"), "data", "attributes") + if attrs["firstName"] != "Team" || attrs["lastName"] != "Member" { + t.Errorf("names must come from the team record: %v", attrs) + } +} + +func TestAddTesterInternalGroupInvitesToTeam(t *testing.T) { + f := newFake(t) + var log bytes.Buffer + c := f.client(t) + _, err := AddTester(context.Background(), c, &TesterOptions{AppID: "app-1", Group: internalGroup, Email: "new@example.com", Log: &log}) + if err == nil || !strings.Contains(err.Error(), "--first") { + t.Errorf("names are required for a team invitation: %v", err) + } + if f.called("POST /v1/userInvitations") || f.called("POST /v1/betaTesters") { + t.Errorf("nothing may be sent without names: %v", f.calls) + } + + res, err := AddTester(context.Background(), c, &TesterOptions{AppID: "app-1", Group: internalGroup, Email: "new@example.com", FirstName: "New", LastName: "Person", TeamRole: "DEVELOPER", Log: &log}) + if err != nil || res.Status != TesterTeamInviteSent || res.ID != "inv-1" { + t.Fatalf("result = %+v, err = %v", res, err) + } + invite := obj(t, f.body("POST /v1/userInvitations"), "data") + attrs := obj(t, invite, "attributes") + if attrs["email"] != "new@example.com" || attrs["firstName"] != "New" || attrs["lastName"] != "Person" || attrs["allAppsVisible"] != false || arr(t, attrs, "roles")[0] != "DEVELOPER" { + t.Errorf("invitation = %v", attrs) + } + if apps := arr(t, invite, "relationships", "visibleApps", "data"); len(apps) != 1 || obj(t, apps[0])["id"] != "app-1" { + t.Errorf("visibleApps = %v", invite["relationships"]) + } + if f.called("POST /v1/betaTesters") || f.called("POST /v1/betaGroups/g-int/relationships/betaTesters") { + t.Errorf("no tester record exists before the invitation is accepted: %v", f.calls) + } + if !strings.Contains(log.String(), "Invited new@example.com to the team as DEVELOPER; they must accept the email") { + t.Errorf("log = %q", log.String()) + } + + // A second run before they accept finds the pending invitation and sends nothing. + f = newFake(t) + f.pendingInvite = true + log.Reset() + res, err = AddTester(context.Background(), f.client(t), &TesterOptions{AppID: "app-1", Group: internalGroup, Email: "new@example.com", Log: &log}) + if err != nil || res.Status != TesterTeamInvitePending || res.ID != "inv-0" || f.called("POST /v1/userInvitations") { + t.Fatalf("result = %+v, err = %v, calls = %v", res, err, f.calls) + } + if !strings.Contains(log.String(), "already has a pending team invitation") { + t.Errorf("log = %q", log.String()) + } +} diff --git a/internal/distribute/testflight.go b/internal/distribute/testflight.go index a43cfe8..cd51e42 100644 --- a/internal/distribute/testflight.go +++ b/internal/distribute/testflight.go @@ -16,9 +16,11 @@ type TestFlightOptions struct { // Version and BuildNumber narrow the build; empty picks the newest VALID build. Version string BuildNumber string - // Groups are TestFlight group names (case-insensitive). Empty adds the - // build nowhere and reports the available groups instead. - Groups []string + // Groups are TestFlight group names (case-insensitive); an unknown name is + // created, internal or external with External. Empty adds the build + // nowhere and reports the available groups instead. + Groups []string + External bool // Notes is the "What to Test" text; Locale defaults to the app's primary locale. Notes string Locale string @@ -35,6 +37,11 @@ type GroupRef struct { ID string `json:"id"` Name string `json:"name"` Internal bool `json:"internal"` + // Created is set when the group did not exist and was made for this submit. + Created bool `json:"created,omitempty"` + // AutoBuilds marks an internal group with automatic distribution; the + // build was not added to it because every build already reaches it. + AutoBuilds bool `json:"auto_builds,omitempty"` } // ReviewRef describes a review's state. @@ -98,13 +105,14 @@ func SubmitTestFlight(ctx context.Context, client *asc.Client, opts *TestFlightO for _, g := range groups { res.AvailableGroups = append(res.AvailableGroups, GroupRef{ID: g.ID, Name: g.Name, Internal: g.Internal}) } - logf(opts.Log, "No --group given; the build was added to no TestFlight group. Available groups:") + logf(opts.Log, "No --group given; the build was added to no TestFlight group.") + if len(groups) == 0 { + logf(opts.Log, "Available groups: (none)") + } else { + logf(opts.Log, "Available groups:") + } for _, g := range groups { - kind := "external" - if g.Internal { - kind = "internal" - } - logf(opts.Log, " %s (%s)", g.Name, kind) + logf(opts.Log, " %s (%s)", g.Name, groupKind(g.Internal)) } if res.Compliance == "pending" { logf(opts.Log, "Export compliance is unanswered (Missing Compliance); pass --no-encryption if the app uses no non-exempt encryption.") @@ -112,34 +120,29 @@ func SubmitTestFlight(ctx context.Context, client *asc.Client, opts *TestFlightO return res, nil } - var ids []string + var ids, names []string var external bool - var unknown []string for _, name := range opts.Groups { - found := false - for _, g := range groups { - if strings.EqualFold(g.Name, name) { - ids = append(ids, g.ID) - res.Groups = append(res.Groups, GroupRef{ID: g.ID, Name: g.Name, Internal: g.Internal}) - external = external || !g.Internal - found = true - break - } - } - if !found { - unknown = append(unknown, name) + g, err := findOrCreateGroup(ctx, client, opts.Log, app.ID, groups, name, !opts.External) + if err != nil { + return res, err } - } - if len(unknown) > 0 { - names := make([]string, 0, len(groups)) - for _, g := range groups { - names = append(names, g.Name) + res.Groups = append(res.Groups, *g) + if g.AutoBuilds { + logf(opts.Log, "%s is an internal group with automatic distribution: every processed build is already available to its testers", g.Name) + continue } - return res, fmt.Errorf("no TestFlight group named %s; %s has: %s", strings.Join(unknown, ", "), app.Name, strings.Join(names, ", ")) + ids = append(ids, g.ID) + names = append(names, g.Name) + external = external || !g.Internal } if res.Compliance == "pending" { return res, fmt.Errorf("build %s has no export compliance answer, so TestFlight cannot distribute it; pass --no-encryption if the app uses no non-exempt encryption, or answer in App Store Connect", build.BuildNumber) } + if len(ids) == 0 { + logf(opts.Log, "TestFlight: %s", res.Link) + return res, nil + } if external { review, err := client.GetBuildBetaAppReviewSubmission(ctx, build.ID) @@ -161,7 +164,7 @@ func SubmitTestFlight(ctx context.Context, client *asc.Client, opts *TestFlightO if err := client.AddBuildToBetaGroups(ctx, build.ID, ids); err != nil { return res, fmt.Errorf("add build to groups: %w", err) } - logf(opts.Log, "Added build %s to %s", build.BuildNumber, strings.Join(opts.Groups, ", ")) + logf(opts.Log, "Added build %s to %s", build.BuildNumber, strings.Join(names, ", ")) if opts.Wait && res.BetaReview != nil { review, err := client.WaitForBetaAppReview(ctx, res.BetaReview.ID, pollInterval(opts.PollInterval), func(r *asc.BetaAppReviewSubmission) { @@ -180,3 +183,26 @@ func SubmitTestFlight(ctx context.Context, client *asc.Client, opts *TestFlightO logf(opts.Log, "TestFlight: %s", res.Link) return res, nil } + +func groupKind(internal bool) string { + if internal { + return "internal" + } + return "external" +} + +// findOrCreateGroup matches name against the app's groups (case-insensitive) +// and creates it when none matches. Existing groups keep their type. +func findOrCreateGroup(ctx context.Context, client *asc.Client, log io.Writer, appID string, groups []asc.BetaGroup, name string, internal bool) (*GroupRef, error) { + if g, err := asc.MatchBetaGroup(groups, name); err != nil { + return nil, err + } else if g != nil { + return &GroupRef{ID: g.ID, Name: g.Name, Internal: g.Internal, AutoBuilds: g.Internal && g.HasAccessToAllBuilds}, nil + } + g, err := client.CreateBetaGroup(ctx, asc.BetaGroupSpec{AppID: appID, Name: name, Internal: internal}) + if err != nil { + return nil, fmt.Errorf("create TestFlight group %s: %w", name, err) + } + logf(log, "Created TestFlight group %s (%s)", g.Name, groupKind(g.Internal)) + return &GroupRef{ID: g.ID, Name: g.Name, Internal: g.Internal, Created: true}, nil +} diff --git a/internal/distribute/upload.go b/internal/distribute/upload.go index a608497..1eb1f94 100644 --- a/internal/distribute/upload.go +++ b/internal/distribute/upload.go @@ -24,6 +24,16 @@ type UploadOptions struct { Log io.Writer } +// progressSize renders "sent/total" in MB with one decimal, or in KB while +// the whole upload is under a megabyte, so a small IPA never reads 0/0. +func progressSize(sent, total int64) string { + if total < 1<<20 { + return fmt.Sprintf("%d/%d KB", sent>>10, total>>10) + } + const mb = float64(1 << 20) + return fmt.Sprintf("%.1f/%.1f MB", float64(sent)/mb, float64(total)/mb) +} + // IPARef describes the uploaded archive. type IPARef struct { Path string `json:"path"` @@ -84,7 +94,7 @@ func Upload(ctx context.Context, client *asc.Client, opts *UploadOptions) (*Uplo } if pct := sent * 100 / total; pct/10 > lastPercent/10 || pct == 100 { lastPercent = pct - logf(opts.Log, " %d%% (%d/%d MB)", pct, sent>>20, total>>20) + logf(opts.Log, " %d%% (%s)", pct, progressSize(sent, total)) } }, })