package console import ( "context" "encoding/json" "strings" "testing" "time" "github.com/carlosframework/platform/internal/domains" "github.com/carlosframework/platform/internal/release" ) // The manifest is where account identity LEAVES the console: the registry on // every box materializes exactly what this writes, and never assigns anything // of its own. func TestWriteAccountManifestRoundTrips(t *testing.T) { s := release.NewDirStore(t.TempDir()) ctx := context.Background() if err := WriteAccountManifest(ctx, s, 42, "cdef", "Kass", time.Unix(1700000000, 0)); err != nil { t.Fatal(err) } body, _, err := s.Get(ctx, "accounts/cdef/account.json") if err != nil { t.Fatal(err) } var m struct { ID int64 `json:"id"` Sqid string `json:"sqid"` Name string `json:"name"` CreatedAt string `json:"created_at"` } if err := json.Unmarshal(body, &m); err != nil { t.Fatal(err) } if m.ID != 42 || m.Sqid != "cdef" || m.Name != "Kass" || m.CreatedAt == "" { t.Errorf("manifest = %+v", m) } // Overwrite-safe: a rename must land, not lose to the CAS gate. if err := WriteAccountManifest(ctx, s, 42, "cdef", "Kassidy", time.Unix(1700000000, 0)); err != nil { t.Fatalf("rewrite: %v", err) } body, _, _ = s.Get(ctx, "accounts/cdef/account.json") json.Unmarshal(body, &m) if m.Name != "Kassidy" { t.Errorf("rewrite lost the rename: %+v", m) } } // The sqid a minted account carries must be the frozen encoder over its id — // nothing else, ever, or a platform domain stops resolving to its account. func TestMintedSqidIsTheFrozenEncoding(t *testing.T) { for id := int64(2); id < 60; id++ { if got := domains.Sqid(id); got != domains.Sqid(id) || got == "" { t.Fatalf("Sqid(%d) unstable/empty: %q", id, got) } } if got := domains.Sqid(1); got != release.OpsAccount { t.Fatalf("ops sqid = %q, want %q — the frozen encoding moved", got, release.OpsAccount) } } // Account ids are SEQUENTIAL, and allocated in the BUCKET // (domains.MintAccount) — not by the registry, not by a console row number. // Identity survives a registry rebuild for the reason it always did: minted // once, recorded in the manifest, materialized-never-assigned. What sequential // buys is a sqid a human can use: a 40-bit random id encoded to NINE // characters (kass.nyfbskdye.oncarlos.com), which nobody can read out over a // phone or type without a typo. It gives up nothing in secrecy, because sqids // were never secret — every platform hostname's certificate is published in // Certificate Transparency within seconds of issue. func TestConsoleMintsSequentialIdsIntoTheBucket(t *testing.T) { rel := release.NewDirStore(t.TempDir()) ctx := t.Context() var last int64 for i := 0; i < 3; i++ { id, sqid, err := domains.MintAccount(ctx, rel, "Account", "", time.Now()) if err != nil { t.Fatal(err) } if id <= domains.ReservedAccountID { t.Fatalf("minted id %d collides with ops/zero", id) } if last != 0 && id != last+1 { t.Errorf("minted %d after %d, want the next integer", id, last) } if len(sqid) > 3 { t.Errorf("sqid %q is %d characters — the whole point is that it is short", sqid, len(sqid)) } last = id } } func TestCreateAccountMakesOwner(t *testing.T) { st := openTestStore(t) if err := st.CreateAccount(42, "cdef", "Kass", "paul@keymail.dev", time.Now()); err != nil { t.Fatal(err) } role, _ := st.Role(42, "paul@keymail.dev") if role != "owner" { t.Errorf("creator role = %q, want owner", role) } if role, _ := st.Role(42, "someone@keymail.dev"); role != "" { t.Errorf("non-member role = %q, want empty", role) } accts, err := st.AccountsFor("paul@keymail.dev") if err != nil { t.Fatal(err) } if len(accts) != 1 || accts[0].Sqid != "cdef" || accts[0].Role != "owner" || accts[0].Name != "Kass" { t.Errorf("AccountsFor = %+v", accts) } if accts, _ := st.AccountsFor("someone@keymail.dev"); len(accts) != 0 { t.Errorf("a non-member sees accounts: %+v", accts) } } // Every account has at least one owner, always: the last one cannot be // removed (transfer first). Otherwise an account becomes unadministrable — // nobody left who can invite, rename, or delete it. func TestLastOwnerCannotBeRemoved(t *testing.T) { st := openTestStore(t) if err := st.CreateAccount(42, "cdef", "Kass", "paul@keymail.dev", time.Now()); err != nil { t.Fatal(err) } if err := st.RemoveMember(42, "paul@keymail.dev"); err == nil { t.Fatal("removing the last owner must fail") } // A member is not a second owner: demoting the only owner is the same // hole by another name. if err := st.AddMember(42, "kim@keymail.dev", "member", time.Now()); err != nil { t.Fatal(err) } if err := st.RemoveMember(42, "paul@keymail.dev"); err == nil { t.Fatal("a member does not make the owner removable") } if err := st.AddMember(42, "sam@keymail.dev", "owner", time.Now()); err != nil { t.Fatal(err) } if err := st.RemoveMember(42, "paul@keymail.dev"); err != nil { t.Fatalf("removable once a second owner exists: %v", err) } } func TestLastOwnerCannotBeDemoted(t *testing.T) { st := openTestStore(t) if err := st.CreateAccount(42, "cdef", "Kass", "paul@keymail.dev", time.Now()); err != nil { t.Fatal(err) } if err := st.AddMember(42, "paul@keymail.dev", "member", time.Now()); err == nil { t.Fatal("demoting the last owner must fail") } if role, _ := st.Role(42, "paul@keymail.dev"); role != "owner" { t.Errorf("refused demotion still changed the role: %q", role) } } func TestInviteSingleUseAndExpiring(t *testing.T) { st := openTestStore(t) now := time.Now() if err := st.CreateAccount(42, "cdef", "Kass", "paul@keymail.dev", now); err != nil { t.Fatal(err) } tok, err := st.CreateInvite(42, "sam@keymail.dev", "paul@keymail.dev", now) if err != nil { t.Fatal(err) } if _, err := st.AcceptInvite(tok, "wrong@keymail.dev", now); err == nil { t.Error("an invite must only be acceptable by its addressee") } if _, err := st.AcceptInvite(tok, "sam@keymail.dev", now); err != nil { t.Fatal(err) } if role, _ := st.Role(42, "sam@keymail.dev"); role != "member" { t.Errorf("accepted invite role = %q, want member", role) } if _, err := st.AcceptInvite(tok, "sam@keymail.dev", now); err == nil { t.Error("invite must be single-use") } tok2, _ := st.CreateInvite(42, "kim@keymail.dev", "paul@keymail.dev", now) if _, err := st.AcceptInvite(tok2, "kim@keymail.dev", now.Add(8*24*time.Hour)); err == nil { t.Error("invite must expire after 7 days") } } func TestInvitesListedAndRevocable(t *testing.T) { st := openTestStore(t) now := time.Now() if err := st.CreateAccount(42, "cdef", "Kass", "paul@keymail.dev", now); err != nil { t.Fatal(err) } tok, _ := st.CreateInvite(42, "sam@keymail.dev", "paul@keymail.dev", now) pending, err := st.PendingInvites(42) if err != nil || len(pending) != 1 || pending[0].Address != "sam@keymail.dev" { t.Fatalf("PendingInvites = %+v %v", pending, err) } if err := st.RevokeInvite(tok); err != nil { t.Fatal(err) } if pending, _ := st.PendingInvites(42); len(pending) != 0 { t.Errorf("revoked invite still pending: %+v", pending) } if _, err := st.AcceptInvite(tok, "sam@keymail.dev", now); err == nil { t.Error("a revoked invite must not be acceptable") } } func TestLastAccountRoundTrips(t *testing.T) { st := openTestStore(t) if err := st.CreateAccount(42, "cdef", "Kass", "paul@keymail.dev", time.Now()); err != nil { t.Fatal(err) } // No history yet is 0, not an error. if got, err := st.LastAccount("paul@keymail.dev"); err != nil || got != 0 { t.Fatalf("LastAccount before any visit = %d, %v; want 0, nil", got, err) } if err := st.SetLastAccount("paul@keymail.dev", 42); err != nil { t.Fatal(err) } if got, _ := st.LastAccount("paul@keymail.dev"); got != 42 { t.Errorf("LastAccount = %d, want 42", got) } // It works for an address that has never signed in (the invite case). if err := st.SetLastAccount("newcomer@keymail.dev", 42); err != nil { t.Fatal(err) } if got, _ := st.LastAccount("newcomer@keymail.dev"); got != 42 { t.Errorf("LastAccount(newcomer) = %d, want 42", got) } } // A per-app grant was never deployment-wide authority, and membership IS // authorization: "member of the account AND the account claims the app" means // one membership of ops is access to console, platform-site, status and every // other app that lives there. Sweeping grant holders into ops did exactly that // on the flagship — someone holding ONE grant for a toy app could promote and // roll back kass, keymail and the console. Non-operators must stay OUT, and be // reported so they get accounts of their own. func TestBackfillLeavesGrantHoldersOutOfOpsAndReportsThem(t *testing.T) { st := openTestStore(t) for _, g := range [][2]string{ {"dev@keymail.dev", "toy"}, {"dev@keymail.dev", "another-toy"}, {"ops@keymail.dev", "console"}, } { if err := st.Grant(g[0], g[1]); err != nil { t.Fatal(err) } } if err := st.SetOperator("ops@keymail.dev", true); err != nil { t.Fatal(err) } needs, err := st.BackfillOpsMemberships(time.Now()) if err != nil { t.Fatal(err) } if role, _ := st.Role(OpsAccountID, "dev@keymail.dev"); role != "" { t.Errorf("app-grant holder got role %q in ops — that is the widening this fixes", role) } if role, _ := st.Role(OpsAccountID, "ops@keymail.dev"); role != RoleOwner { t.Errorf("operator role = %q, want owner", role) } // Left out is not the same as forgotten: the operator has to be told who // needs an account, with their apps, or the migration silently loses them. if len(needs) != 1 || needs[0].Address != "dev@keymail.dev" { t.Fatalf("report = %+v, want just dev@keymail.dev", needs) } if got := strings.Join(needs[0].Apps, ","); got != "another-toy,toy" { t.Errorf("reported apps = %q, want both, sorted", got) } // The app names are still claimed in ops, which is where every // pre-accounts app lived — migrate-app is what moves them out. apps, _ := st.AppsIn(OpsAccountID) if len(apps) != 3 { t.Errorf("AppsIn(ops) = %v, want every granted app claimed", apps) } // Idempotent: a re-run changes nothing and reports the same people, so a // deployment that has already run this (with the OLD semantics, cleaned up // by hand) neither loses the cleanup nor re-applies the widening. again, err := st.BackfillOpsMemberships(time.Now()) if err != nil { t.Fatal(err) } if len(again) != 1 || again[0].Address != "dev@keymail.dev" { t.Errorf("second report = %+v, want the same", again) } if role, _ := st.Role(OpsAccountID, "dev@keymail.dev"); role != "" { t.Errorf("second run put the grant holder into ops as %q", role) } if role, _ := st.Role(OpsAccountID, "ops@keymail.dev"); role != RoleOwner { t.Errorf("second run changed the operator to %q", role) } } // The flagship shape: the backfill has already run once with the old // semantics, and the widened membership was removed by hand. Re-running must // not put it back, and must not touch a membership an operator has since // created deliberately with `console member`. func TestBackfillNeverResurrectsARemovedMembership(t *testing.T) { st := openTestStore(t) now := time.Now() if err := st.Grant("dev@keymail.dev", "toy"); err != nil { t.Fatal(err) } if err := st.Grant("paul@keymail.dev", "kass"); err != nil { t.Fatal(err) } if err := st.SetOperator("paul@keymail.dev", true); err != nil { t.Fatal(err) } if _, err := st.BackfillOpsMemberships(now); err != nil { t.Fatal(err) } // An operator deliberately admits somebody as a plain member. if err := st.AddMember(OpsAccountID, "trusted@keymail.dev", RoleMember, now); err != nil { t.Fatal(err) } needs, err := st.BackfillOpsMemberships(now) if err != nil { t.Fatal(err) } if role, _ := st.Role(OpsAccountID, "dev@keymail.dev"); role != "" { t.Errorf("the removed membership came back as %q", role) } if role, _ := st.Role(OpsAccountID, "trusted@keymail.dev"); role != RoleMember { t.Errorf("deliberate membership changed to %q, want member", role) } // Somebody an operator has already dealt with is not reported again. for _, h := range needs { if h.Address == "trusted@keymail.dev" || h.Address == "paul@keymail.dev" { t.Errorf("reported %s, who is already a member of ops", h.Address) } } } // With grants but NO operator, nobody is promoted. The old code promoted the // earliest grant holder to OWNER of ops so the account would have someone who // could administer it — but ops holds the console itself, and that promotion // is the same widening by a quieter route. An ownerless ops is recoverable // box-side (`console member -add bab -role owner`), which is precisely // the tool that did not exist when that fallback was written. func TestBackfillPromotesNobodyWhenThereIsNoOperator(t *testing.T) { st := openTestStore(t) base := time.Unix(1700000000, 0) if err := st.GrantAt("second@keymail.dev", "b", base.Add(time.Hour)); err != nil { t.Fatal(err) } if err := st.GrantAt("first@keymail.dev", "a", base); err != nil { t.Fatal(err) } needs, err := st.BackfillOpsMemberships(time.Now()) if err != nil { t.Fatal(err) } members, err := st.Members(OpsAccountID) if err != nil { t.Fatal(err) } if len(members) != 0 { t.Errorf("Members(ops) = %+v, want nobody — a grant is not authority over ops", members) } if len(needs) != 2 { t.Errorf("report = %+v, want both grant holders", needs) } } // AdoptManifestOwner is how a bucket manifest's `owner` becomes a membership. // It only ever fills a VACANCY: the manifest names the account's first owner, // and once an account has one, membership is the console's business and a // re-read of a write-once object must not touch it. func TestAdoptManifestOwnerOnlyEverFillsAVacancy(t *testing.T) { st := openTestStore(t) now := time.Unix(1700000000, 0) // The shape `carlos accounts create` leaves: a row with no members at all. if _, err := st.EnsureAccount(3, "bac", "Kass", now); err != nil { t.Fatal(err) } adopted, err := st.AdoptManifestOwner(3, "paul@keymail.dev", now) if err != nil || !adopted { t.Fatalf("AdoptManifestOwner = %v %v, want it to fill the vacancy", adopted, err) } if role, _ := st.Role(3, "paul@keymail.dev"); role != RoleOwner { t.Errorf("role after adoption = %q, want owner", role) } // Idempotent: every console start re-reads the same manifest, so a second // pass must report "nothing to do" rather than re-writing. if adopted, err := st.AdoptManifestOwner(3, "paul@keymail.dev", now); err != nil || adopted { t.Errorf("second adoption = %v %v, want false", adopted, err) } // An account that already has an owner is administered by the console now. // A manifest naming somebody else must NOT hand them authority — that would // make a write-once bucket object a standing grant, re-applied on every // restart, exactly the shape of the backfill bug above. if adopted, err := st.AdoptManifestOwner(3, "someone@keymail.dev", now); err != nil || adopted { t.Errorf("adoption against an owned account = %v %v, want false", adopted, err) } if role, _ := st.Role(3, "someone@keymail.dev"); role != "" { t.Errorf("a manifest gave %q to a stranger on an already-owned account", role) } if role, _ := st.Role(3, "paul@keymail.dev"); role != RoleOwner { t.Errorf("the existing owner was changed to %q", role) } } // The account has members but no OWNER — an invite was accepted (invites always // grant member) into an account `carlos accounts create` left ownerless. The // manifest's owner is the one the operator named, so they are promoted rather // than added twice. func TestAdoptManifestOwnerPromotesTheNamedMember(t *testing.T) { st := openTestStore(t) now := time.Unix(1700000000, 0) if _, err := st.EnsureAccount(3, "bac", "Kass", now); err != nil { t.Fatal(err) } if err := st.AddMember(3, "paul@keymail.dev", RoleMember, now); err != nil { t.Fatal(err) } adopted, err := st.AdoptManifestOwner(3, "paul@keymail.dev", now) if err != nil || !adopted { t.Fatalf("AdoptManifestOwner = %v %v, want the named member promoted", adopted, err) } if role, _ := st.Role(3, "paul@keymail.dev"); role != RoleOwner { t.Errorf("role = %q, want owner", role) } } // FOUND IN PRODUCTION, not in review — and it was undoing the migration it // ran alongside. // // `carlos accounts migrate` + `console migrate-app` moved jelly out of ops and // into account bac. The legacy `owners` grant for jelly stays readable on // purpose (the backfill's comment says so: the grants stay while the migration // is verified). The very next console RESTART re-ran this backfill, saw a grant // for "jelly", saw no jelly row in OPS, and created one — so the same app // existed in two accounts at once, and kass and keymail were queued to get the // same treatment on the restart after that. Every migration would have been // undone, indefinitely, by the ordinary act of restarting the console. // // The rule the SQL was missing: the backfill claims a name that nothing claims // yet. An app with a row in ANY account has already been placed — by the // migration, by the console, by a previous backfill — and placing it again is // not a claim, it is a duplicate. func TestBackfillNeverRecreatesAnAppThatLivesInAnotherAccount(t *testing.T) { st := openTestStore(t) now := time.Unix(1700000000, 0) const migrated = int64(3) if err := st.CreateAccount(migrated, "bac", "Jelly", "paul@keymail.dev", now); err != nil { t.Fatal(err) } if _, err := st.CreateAppIn(migrated, "jelly", "paul@keymail.dev", now); err != nil { t.Fatal(err) } // Exactly the live shape: the app is migrated, and its pre-accounts grant // is still sitting in the owners table waiting to be revoked. if err := st.GrantAt("dev@keymail.dev", "jelly", now); err != nil { t.Fatal(err) } // A grant-only app that was never migrated still has to be claimed in ops — // that is the job this function exists for, and the fix must not cost it. if err := st.GrantAt("dev@keymail.dev", "toy", now); err != nil { t.Fatal(err) } if _, err := st.BackfillOpsMemberships(now.Add(time.Hour)); err != nil { t.Fatal(err) } if ok, err := st.HasAppIn(OpsAccountID, "jelly"); err != nil || ok { t.Error("the backfill recreated jelly in ops — the migration is undone on every restart") } holders, err := st.AppAccounts("jelly") if err != nil { t.Fatal(err) } if len(holders) != 1 || holders[0] != migrated { t.Errorf("jelly is claimed by accounts %v, want only %d", holders, migrated) } // The surviving row is the migrated one, unchanged: same owner, same // creation time. A backfill that "fixed" it by rewriting the row would be // the same bug wearing a different hat. var createdBy string var createdAt int64 if err := st.db.QueryRow(`SELECT created_by, created_at FROM apps WHERE app = 'jelly'`). Scan(&createdBy, &createdAt); err != nil { t.Fatal(err) } if createdBy != "paul@keymail.dev" || createdAt != now.Unix() { t.Errorf("migrated jelly row = (%s, %d), want it untouched (paul@keymail.dev, %d)", createdBy, createdAt, now.Unix()) } if ok, err := st.HasAppIn(OpsAccountID, "toy"); err != nil || !ok { t.Error("a grant-only app that was never migrated lost its ops claim") } } // The advice is the whole point of the report: an operator reading the log // must be able to copy three commands per person, not go and design a // migration. It names the apps, so nobody is left behind. func TestGrantHolderAdviceIsCopyPasteable(t *testing.T) { msg := grantHolderAdvice([]GrantHolder{ {Address: "dev@keymail.dev", Apps: []string{"toy", "other"}}, {Address: "sam@keymail.dev", Apps: []string{"blog"}}, }) for _, want := range []string{ "2 grant holders need accounts", "dev@keymail.dev", "toy", "other", "sam@keymail.dev", "blog", // The -owner flag is required, so advice that omits it is advice that // fails when pasted. And the `console member -add … -role owner` step is // gone: the manifest owner materializes into the membership, which is // the point — a step an operator can skip is a step that produces an // account nobody can open, as it did in production. "carlos accounts create --name --owner
", "console migrate-app", } { if !strings.Contains(msg, want) { t.Errorf("advice does not mention %q: %s", want, msg) } } if grantHolderAdvice(nil) != "" { t.Errorf("advice for nobody = %q, want silence", grantHolderAdvice(nil)) } } // The console half of `carlos accounts migrate`: the app→account row moves, // and re-running is a no-op rather than an error — an operator who is not // sure whether the move landed must be able to just run it again. func TestMoveAppBetweenAccounts(t *testing.T) { st := openTestStore(t) now := time.Now() if err := st.CreateAccount(OpsAccountID, opsSqid, "ops", "paul@keymail.dev", now); err != nil { t.Fatal(err) } if err := st.CreateAccount(42, "cdef", "Kass", "paul@keymail.dev", now); err != nil { t.Fatal(err) } if _, err := st.CreateAppIn(OpsAccountID, "kass", "paul@keymail.dev", now); err != nil { t.Fatal(err) } if got, err := st.AppAccounts("kass"); err != nil || len(got) != 1 || got[0] != OpsAccountID { t.Fatalf("AppAccounts = %v %v, want [1]", got, err) } if err := st.MoveApp("kass", OpsAccountID, 42); err != nil { t.Fatal(err) } if got, _ := st.AppAccounts("kass"); len(got) != 1 || got[0] != 42 { t.Fatalf("after move AppAccounts = %v, want [42]", got) } if apps, _ := st.AppsIn(OpsAccountID); len(apps) != 0 { t.Errorf("app still in the source account: %v", apps) } // Idempotent: the row is already where it was asked to go. if err := st.MoveApp("kass", OpsAccountID, 42); err != nil { t.Fatalf("re-running the move must be a no-op: %v", err) } if got, _ := st.AppAccounts("kass"); len(got) != 1 || got[0] != 42 { t.Fatalf("re-run changed things: %v", got) } // Moving onto a name the destination already holds is refused, not // silently merged: two different apps would become one row. if _, err := st.CreateAppIn(OpsAccountID, "kass", "paul@keymail.dev", now); err != nil { t.Fatal(err) } if err := st.MoveApp("kass", OpsAccountID, 42); err == nil { t.Error("moving onto an existing name must fail") } if got, _ := st.AppAccounts("kass"); len(got) != 2 { t.Errorf("refused move lost a row: %v", got) } // The app's other rows are untouched by a move of one of them. if got, err := st.AppAccounts("nosuchapp"); err != nil || len(got) != 0 { t.Errorf("AppAccounts(absent) = %v %v, want empty nil", got, err) } } // Account names are deliberately not unique — identity is the sqid — and // AccountsFor must order deterministically (creation order) so "your first // membership" is a stable answer. func TestAccountsForOrdersByCreation(t *testing.T) { st := openTestStore(t) now := time.Now() if err := st.CreateAccount(7, "cdef", "Blog", "paul@keymail.dev", now); err != nil { t.Fatal(err) } if err := st.CreateAccount(9, "cdeg", "Blog", "paul@keymail.dev", now.Add(time.Second)); err != nil { t.Fatal(err) } accts, _ := st.AccountsFor("paul@keymail.dev") if len(accts) != 2 || accts[0].Sqid != "cdef" || accts[1].Sqid != "cdeg" { t.Fatalf("AccountsFor = %+v, want cdef then cdeg", accts) } } func TestAccountBySqidAndAppOwnership(t *testing.T) { st := openTestStore(t) now := time.Now() if err := st.CreateAccount(42, "cdef", "Kass", "paul@keymail.dev", now); err != nil { t.Fatal(err) } a, err := st.AccountBySqid("cdef") if err != nil || a == nil || a.ID != 42 || a.Name != "Kass" { t.Fatalf("AccountBySqid = %+v %v", a, err) } if a, err := st.AccountBySqid("nosuch"); err != nil || a != nil { t.Fatalf("AccountBySqid(absent) = %+v %v, want nil nil", a, err) } // Apps belong to an account now; the same name may exist in two. if created, err := st.CreateAppIn(42, "blog", "paul@keymail.dev", now); err != nil || !created { t.Fatalf("CreateAppIn = %v %v", created, err) } if created, err := st.CreateAppIn(42, "blog", "paul@keymail.dev", now); err != nil || created { t.Fatalf("second CreateAppIn in the same account = %v %v, want false nil", created, err) } if err := st.CreateAccount(43, "cdeg", "Other", "sam@keymail.dev", now); err != nil { t.Fatal(err) } if created, err := st.CreateAppIn(43, "blog", "sam@keymail.dev", now); err != nil || !created { t.Fatalf("same app name in another account must be free: %v %v", created, err) } apps, err := st.AppsIn(42) if err != nil || len(apps) != 1 || apps[0] != "blog" { t.Fatalf("AppsIn(42) = %v %v", apps, err) } }