feat(app): add repository use cases with access rule
- commit
- c97ed5709e6103ab2cd9adf87f3cb466d1264076
- author
- brice · · 2026-10-10 06:09 UTC
- parent
- 4c5e5e6
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
3 files · +545 -0
A internal/app/access.go +19 -0
| @@ -0,0 +1,19 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package app | |
| 4 | + | ||
| 5 | + | import "bnema.dev/ermite/internal/domain" | |
| 6 | + | ||
| 7 | + | // canRead is the access rule for reading a repository, the single place that | |
| 8 | + | // decides it: root reads everything, anonymous callers read public | |
| 9 | + | // repositories only. Callers answer domain.ErrNotFound when it is false, so | |
| 10 | + | // a private repository never shows itself. | |
| 11 | + | func canRead(p domain.Principal, repo domain.Repo) bool { | |
| 12 | + | if p.IsRoot() { | |
| 13 | + | return true | |
| 14 | + | } | |
| 15 | + | return repo.Visibility == domain.VisibilityPublic | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | // canCreate is the access rule for creating a repository: root only. | |
| 19 | + | func canCreate(p domain.Principal) bool { return p.IsRoot() } | |
| @@ -0,0 +1,19 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package app | |||
| 4 | + | ||||
| 5 | + | import "bnema.dev/ermite/internal/domain" | |||
| 6 | + | ||||
| 7 | + | // canRead is the access rule for reading a repository, the single place that | |||
| 8 | + | // decides it: root reads everything, anonymous callers read public | |||
| 9 | + | // repositories only. Callers answer domain.ErrNotFound when it is false, so | |||
| 10 | + | // a private repository never shows itself. | |||
| 11 | + | func canRead(p domain.Principal, repo domain.Repo) bool { | |||
| 12 | + | if p.IsRoot() { | |||
| 13 | + | return true | |||
| 14 | + | } | |||
| 15 | + | return repo.Visibility == domain.VisibilityPublic | |||
| 16 | + | } | |||
| 17 | + | ||||
| 18 | + | // canCreate is the access rule for creating a repository: root only. | |||
| 19 | + | func canCreate(p domain.Principal) bool { return p.IsRoot() } | |||
A internal/app/repos.go +197 -0
| @@ -0,0 +1,197 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | // Package app holds the use cases of ermite. It depends on the domain and on | |
| 4 | + | // the ports only. | |
| 5 | + | package app | |
| 6 | + | ||
| 7 | + | import ( | |
| 8 | + | "context" | |
| 9 | + | "errors" | |
| 10 | + | "fmt" | |
| 11 | + | ||
| 12 | + | "github.com/bnema/zerowrap" | |
| 13 | + | ||
| 14 | + | "bnema.dev/ermite/internal/domain" | |
| 15 | + | "bnema.dev/ermite/internal/ports" | |
| 16 | + | ) | |
| 17 | + | ||
| 18 | + | // Repos are the repository use cases. | |
| 19 | + | type Repos struct { | |
| 20 | + | meta ports.RepoMetaStore | |
| 21 | + | git ports.GitRepos | |
| 22 | + | clock ports.Clock | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | // NewRepos builds the repository use cases over their ports. | |
| 26 | + | func NewRepos(meta ports.RepoMetaStore, git ports.GitRepos, clock ports.Clock) *Repos { | |
| 27 | + | return &Repos{meta: meta, git: git, clock: clock} | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | // CreateParams are the settings of a new repository. | |
| 31 | + | type CreateParams struct { | |
| 32 | + | Name domain.RepoName | |
| 33 | + | Description string | |
| 34 | + | // Visibility defaults to private when empty. | |
| 35 | + | Visibility domain.Visibility | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | // Create makes a repository: it stages a bare repository, records the | |
| 39 | + | // metadata (which fails with domain.ErrAlreadyExists if the name is taken, | |
| 40 | + | // also when a concurrent Create wins) and publishes the directory. A failure | |
| 41 | + | // after the metadata is written removes it again and discards the staged | |
| 42 | + | // repository. | |
| 43 | + | // | |
| 44 | + | // Only root may create; anonymous callers get domain.ErrUnauthenticated. | |
| 45 | + | func (r *Repos) Create(ctx context.Context, p domain.Principal, params CreateParams) (domain.Repo, error) { | |
| 46 | + | if !canCreate(p) { | |
| 47 | + | return domain.Repo{}, domain.ErrUnauthenticated | |
| 48 | + | } | |
| 49 | + | if params.Name.IsZero() { | |
| 50 | + | return domain.Repo{}, fmt.Errorf("%w: empty name", domain.ErrInvalidName) | |
| 51 | + | } | |
| 52 | + | vis := params.Visibility | |
| 53 | + | if vis == "" { | |
| 54 | + | vis = domain.VisibilityPrivate | |
| 55 | + | } | |
| 56 | + | if _, err := domain.ParseVisibility(vis.String()); err != nil { | |
| 57 | + | return domain.Repo{}, err | |
| 58 | + | } | |
| 59 | + | repo := domain.Repo{ | |
| 60 | + | Name: params.Name, | |
| 61 | + | Description: params.Description, | |
| 62 | + | Visibility: vis, | |
| 63 | + | CreatedAt: r.clock.Now().UTC(), | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | log := zerowrap.FromCtx(ctx) | |
| 67 | + | // Cleanup must finish even when the caller gives up. | |
| 68 | + | cleanup := context.WithoutCancel(ctx) | |
| 69 | + | ||
| 70 | + | id, err := r.git.Stage(ctx) | |
| 71 | + | if err != nil { | |
| 72 | + | return domain.Repo{}, fmt.Errorf("stage repository %q: %w", repo.Name, err) | |
| 73 | + | } | |
| 74 | + | if err := r.meta.Create(ctx, repo); err != nil { | |
| 75 | + | r.discard(cleanup, id) | |
| 76 | + | if errors.Is(err, domain.ErrAlreadyExists) { | |
| 77 | + | return domain.Repo{}, fmt.Errorf("repository %q: %w", repo.Name, domain.ErrAlreadyExists) | |
| 78 | + | } | |
| 79 | + | return domain.Repo{}, fmt.Errorf("store repository %q: %w", repo.Name, err) | |
| 80 | + | } | |
| 81 | + | if err := r.git.Publish(ctx, id, repo.Name); err != nil { | |
| 82 | + | if derr := r.meta.Delete(cleanup, repo.Name); derr != nil { | |
| 83 | + | log.Error().Err(derr).Str("repo", repo.Name.String()).Msg("remove metadata after failed publish") | |
| 84 | + | } | |
| 85 | + | r.discard(cleanup, id) | |
| 86 | + | if errors.Is(err, domain.ErrAlreadyExists) { | |
| 87 | + | return domain.Repo{}, fmt.Errorf("repository %q: %w", repo.Name, domain.ErrAlreadyExists) | |
| 88 | + | } | |
| 89 | + | return domain.Repo{}, fmt.Errorf("publish repository %q: %w", repo.Name, err) | |
| 90 | + | } | |
| 91 | + | log.Info().Str("repo", repo.Name.String()).Str("visibility", vis.String()).Msg("repository created") | |
| 92 | + | return repo, nil | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | func (r *Repos) discard(ctx context.Context, id ports.StagedID) { | |
| 96 | + | if err := r.git.Discard(ctx, id); err != nil { | |
| 97 | + | log := zerowrap.FromCtx(ctx) | |
| 98 | + | log.Error().Err(err).Str("staged", string(id)).Msg("discard staged repository") | |
| 99 | + | } | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | // Get returns a repository. A repository the principal may not read answers | |
| 103 | + | // domain.ErrNotFound, like a missing one. | |
| 104 | + | func (r *Repos) Get(ctx context.Context, p domain.Principal, name domain.RepoName) (domain.Repo, error) { | |
| 105 | + | repo, err := r.meta.Get(ctx, name) | |
| 106 | + | if err != nil { | |
| 107 | + | return domain.Repo{}, err | |
| 108 | + | } | |
| 109 | + | if !canRead(p, repo) { | |
| 110 | + | return domain.Repo{}, domain.ErrNotFound | |
| 111 | + | } | |
| 112 | + | return repo, nil | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | // List returns the repositories the principal may read, ordered by name. | |
| 116 | + | func (r *Repos) List(ctx context.Context, p domain.Principal) ([]domain.Repo, error) { | |
| 117 | + | all, err := r.meta.List(ctx) | |
| 118 | + | if err != nil { | |
| 119 | + | return nil, err | |
| 120 | + | } | |
| 121 | + | visible := make([]domain.Repo, 0, len(all)) | |
| 122 | + | for _, repo := range all { | |
| 123 | + | if canRead(p, repo) { | |
| 124 | + | visible = append(visible, repo) | |
| 125 | + | } | |
| 126 | + | } | |
| 127 | + | return visible, nil | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | // ReconcileReport tells what Reconcile found. | |
| 131 | + | type ReconcileReport struct { | |
| 132 | + | // Registered are directories without metadata, now registered private. | |
| 133 | + | Registered []domain.RepoName | |
| 134 | + | // MissingDirs are repositories with metadata and no directory. They are | |
| 135 | + | // left untouched. | |
| 136 | + | MissingDirs []domain.RepoName | |
| 137 | + | // InvalidDirs are directory entries that cannot be repositories. | |
| 138 | + | InvalidDirs []string | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | // Reconcile aligns the repository directories with the metadata at startup: | |
| 142 | + | // it removes staging leftovers, registers a directory without metadata as | |
| 143 | + | // private, skips invalid directory names, and reports metadata without a | |
| 144 | + | // directory at error level without touching it. | |
| 145 | + | func (r *Repos) Reconcile(ctx context.Context) (ReconcileReport, error) { | |
| 146 | + | log := zerowrap.FromCtx(ctx) | |
| 147 | + | var rep ReconcileReport | |
| 148 | + | ||
| 149 | + | if err := r.git.PurgeStaging(ctx); err != nil { | |
| 150 | + | return rep, fmt.Errorf("purge staging: %w", err) | |
| 151 | + | } | |
| 152 | + | dirs, err := r.git.List(ctx) | |
| 153 | + | if err != nil { | |
| 154 | + | return rep, fmt.Errorf("list repository directories: %w", err) | |
| 155 | + | } | |
| 156 | + | metas, err := r.meta.List(ctx) | |
| 157 | + | if err != nil { | |
| 158 | + | return rep, fmt.Errorf("list repository metadata: %w", err) | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | for _, entry := range dirs.Invalid { | |
| 162 | + | log.Warn().Str("entry", entry).Msg("skip directory that is not a valid repository name") | |
| 163 | + | } | |
| 164 | + | rep.InvalidDirs = dirs.Invalid | |
| 165 | + | ||
| 166 | + | hasMeta := make(map[domain.RepoName]bool, len(metas)) | |
| 167 | + | for _, m := range metas { | |
| 168 | + | hasMeta[m.Name] = true | |
| 169 | + | } | |
| 170 | + | hasDir := make(map[domain.RepoName]bool, len(dirs.Names)) | |
| 171 | + | for _, name := range dirs.Names { | |
| 172 | + | hasDir[name] = true | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | for _, name := range dirs.Names { | |
| 176 | + | if hasMeta[name] { | |
| 177 | + | continue | |
| 178 | + | } | |
| 179 | + | repo := domain.Repo{ | |
| 180 | + | Name: name, | |
| 181 | + | Visibility: domain.VisibilityPrivate, | |
| 182 | + | CreatedAt: r.clock.Now().UTC(), | |
| 183 | + | } | |
| 184 | + | if err := r.meta.Create(ctx, repo); err != nil && !errors.Is(err, domain.ErrAlreadyExists) { | |
| 185 | + | return rep, fmt.Errorf("register repository %q: %w", name, err) | |
| 186 | + | } | |
| 187 | + | log.Warn().Str("repo", name.String()).Msg("registered repository directory without metadata as private") | |
| 188 | + | rep.Registered = append(rep.Registered, name) | |
| 189 | + | } | |
| 190 | + | for _, m := range metas { | |
| 191 | + | if !hasDir[m.Name] { | |
| 192 | + | log.Error().Str("repo", m.Name.String()).Msg("repository metadata without a directory, left untouched") | |
| 193 | + | rep.MissingDirs = append(rep.MissingDirs, m.Name) | |
| 194 | + | } | |
| 195 | + | } | |
| 196 | + | return rep, nil | |
| 197 | + | } | |
| @@ -0,0 +1,197 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | // Package app holds the use cases of ermite. It depends on the domain and on | |||
| 4 | + | // the ports only. | |||
| 5 | + | package app | |||
| 6 | + | ||||
| 7 | + | import ( | |||
| 8 | + | "context" | |||
| 9 | + | "errors" | |||
| 10 | + | "fmt" | |||
| 11 | + | ||||
| 12 | + | "github.com/bnema/zerowrap" | |||
| 13 | + | ||||
| 14 | + | "bnema.dev/ermite/internal/domain" | |||
| 15 | + | "bnema.dev/ermite/internal/ports" | |||
| 16 | + | ) | |||
| 17 | + | ||||
| 18 | + | // Repos are the repository use cases. | |||
| 19 | + | type Repos struct { | |||
| 20 | + | meta ports.RepoMetaStore | |||
| 21 | + | git ports.GitRepos | |||
| 22 | + | clock ports.Clock | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | // NewRepos builds the repository use cases over their ports. | |||
| 26 | + | func NewRepos(meta ports.RepoMetaStore, git ports.GitRepos, clock ports.Clock) *Repos { | |||
| 27 | + | return &Repos{meta: meta, git: git, clock: clock} | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | // CreateParams are the settings of a new repository. | |||
| 31 | + | type CreateParams struct { | |||
| 32 | + | Name domain.RepoName | |||
| 33 | + | Description string | |||
| 34 | + | // Visibility defaults to private when empty. | |||
| 35 | + | Visibility domain.Visibility | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | // Create makes a repository: it stages a bare repository, records the | |||
| 39 | + | // metadata (which fails with domain.ErrAlreadyExists if the name is taken, | |||
| 40 | + | // also when a concurrent Create wins) and publishes the directory. A failure | |||
| 41 | + | // after the metadata is written removes it again and discards the staged | |||
| 42 | + | // repository. | |||
| 43 | + | // | |||
| 44 | + | // Only root may create; anonymous callers get domain.ErrUnauthenticated. | |||
| 45 | + | func (r *Repos) Create(ctx context.Context, p domain.Principal, params CreateParams) (domain.Repo, error) { | |||
| 46 | + | if !canCreate(p) { | |||
| 47 | + | return domain.Repo{}, domain.ErrUnauthenticated | |||
| 48 | + | } | |||
| 49 | + | if params.Name.IsZero() { | |||
| 50 | + | return domain.Repo{}, fmt.Errorf("%w: empty name", domain.ErrInvalidName) | |||
| 51 | + | } | |||
| 52 | + | vis := params.Visibility | |||
| 53 | + | if vis == "" { | |||
| 54 | + | vis = domain.VisibilityPrivate | |||
| 55 | + | } | |||
| 56 | + | if _, err := domain.ParseVisibility(vis.String()); err != nil { | |||
| 57 | + | return domain.Repo{}, err | |||
| 58 | + | } | |||
| 59 | + | repo := domain.Repo{ | |||
| 60 | + | Name: params.Name, | |||
| 61 | + | Description: params.Description, | |||
| 62 | + | Visibility: vis, | |||
| 63 | + | CreatedAt: r.clock.Now().UTC(), | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | log := zerowrap.FromCtx(ctx) | |||
| 67 | + | // Cleanup must finish even when the caller gives up. | |||
| 68 | + | cleanup := context.WithoutCancel(ctx) | |||
| 69 | + | ||||
| 70 | + | id, err := r.git.Stage(ctx) | |||
| 71 | + | if err != nil { | |||
| 72 | + | return domain.Repo{}, fmt.Errorf("stage repository %q: %w", repo.Name, err) | |||
| 73 | + | } | |||
| 74 | + | if err := r.meta.Create(ctx, repo); err != nil { | |||
| 75 | + | r.discard(cleanup, id) | |||
| 76 | + | if errors.Is(err, domain.ErrAlreadyExists) { | |||
| 77 | + | return domain.Repo{}, fmt.Errorf("repository %q: %w", repo.Name, domain.ErrAlreadyExists) | |||
| 78 | + | } | |||
| 79 | + | return domain.Repo{}, fmt.Errorf("store repository %q: %w", repo.Name, err) | |||
| 80 | + | } | |||
| 81 | + | if err := r.git.Publish(ctx, id, repo.Name); err != nil { | |||
| 82 | + | if derr := r.meta.Delete(cleanup, repo.Name); derr != nil { | |||
| 83 | + | log.Error().Err(derr).Str("repo", repo.Name.String()).Msg("remove metadata after failed publish") | |||
| 84 | + | } | |||
| 85 | + | r.discard(cleanup, id) | |||
| 86 | + | if errors.Is(err, domain.ErrAlreadyExists) { | |||
| 87 | + | return domain.Repo{}, fmt.Errorf("repository %q: %w", repo.Name, domain.ErrAlreadyExists) | |||
| 88 | + | } | |||
| 89 | + | return domain.Repo{}, fmt.Errorf("publish repository %q: %w", repo.Name, err) | |||
| 90 | + | } | |||
| 91 | + | log.Info().Str("repo", repo.Name.String()).Str("visibility", vis.String()).Msg("repository created") | |||
| 92 | + | return repo, nil | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | func (r *Repos) discard(ctx context.Context, id ports.StagedID) { | |||
| 96 | + | if err := r.git.Discard(ctx, id); err != nil { | |||
| 97 | + | log := zerowrap.FromCtx(ctx) | |||
| 98 | + | log.Error().Err(err).Str("staged", string(id)).Msg("discard staged repository") | |||
| 99 | + | } | |||
| 100 | + | } | |||
| 101 | + | ||||
| 102 | + | // Get returns a repository. A repository the principal may not read answers | |||
| 103 | + | // domain.ErrNotFound, like a missing one. | |||
| 104 | + | func (r *Repos) Get(ctx context.Context, p domain.Principal, name domain.RepoName) (domain.Repo, error) { | |||
| 105 | + | repo, err := r.meta.Get(ctx, name) | |||
| 106 | + | if err != nil { | |||
| 107 | + | return domain.Repo{}, err | |||
| 108 | + | } | |||
| 109 | + | if !canRead(p, repo) { | |||
| 110 | + | return domain.Repo{}, domain.ErrNotFound | |||
| 111 | + | } | |||
| 112 | + | return repo, nil | |||
| 113 | + | } | |||
| 114 | + | ||||
| 115 | + | // List returns the repositories the principal may read, ordered by name. | |||
| 116 | + | func (r *Repos) List(ctx context.Context, p domain.Principal) ([]domain.Repo, error) { | |||
| 117 | + | all, err := r.meta.List(ctx) | |||
| 118 | + | if err != nil { | |||
| 119 | + | return nil, err | |||
| 120 | + | } | |||
| 121 | + | visible := make([]domain.Repo, 0, len(all)) | |||
| 122 | + | for _, repo := range all { | |||
| 123 | + | if canRead(p, repo) { | |||
| 124 | + | visible = append(visible, repo) | |||
| 125 | + | } | |||
| 126 | + | } | |||
| 127 | + | return visible, nil | |||
| 128 | + | } | |||
| 129 | + | ||||
| 130 | + | // ReconcileReport tells what Reconcile found. | |||
| 131 | + | type ReconcileReport struct { | |||
| 132 | + | // Registered are directories without metadata, now registered private. | |||
| 133 | + | Registered []domain.RepoName | |||
| 134 | + | // MissingDirs are repositories with metadata and no directory. They are | |||
| 135 | + | // left untouched. | |||
| 136 | + | MissingDirs []domain.RepoName | |||
| 137 | + | // InvalidDirs are directory entries that cannot be repositories. | |||
| 138 | + | InvalidDirs []string | |||
| 139 | + | } | |||
| 140 | + | ||||
| 141 | + | // Reconcile aligns the repository directories with the metadata at startup: | |||
| 142 | + | // it removes staging leftovers, registers a directory without metadata as | |||
| 143 | + | // private, skips invalid directory names, and reports metadata without a | |||
| 144 | + | // directory at error level without touching it. | |||
| 145 | + | func (r *Repos) Reconcile(ctx context.Context) (ReconcileReport, error) { | |||
| 146 | + | log := zerowrap.FromCtx(ctx) | |||
| 147 | + | var rep ReconcileReport | |||
| 148 | + | ||||
| 149 | + | if err := r.git.PurgeStaging(ctx); err != nil { | |||
| 150 | + | return rep, fmt.Errorf("purge staging: %w", err) | |||
| 151 | + | } | |||
| 152 | + | dirs, err := r.git.List(ctx) | |||
| 153 | + | if err != nil { | |||
| 154 | + | return rep, fmt.Errorf("list repository directories: %w", err) | |||
| 155 | + | } | |||
| 156 | + | metas, err := r.meta.List(ctx) | |||
| 157 | + | if err != nil { | |||
| 158 | + | return rep, fmt.Errorf("list repository metadata: %w", err) | |||
| 159 | + | } | |||
| 160 | + | ||||
| 161 | + | for _, entry := range dirs.Invalid { | |||
| 162 | + | log.Warn().Str("entry", entry).Msg("skip directory that is not a valid repository name") | |||
| 163 | + | } | |||
| 164 | + | rep.InvalidDirs = dirs.Invalid | |||
| 165 | + | ||||
| 166 | + | hasMeta := make(map[domain.RepoName]bool, len(metas)) | |||
| 167 | + | for _, m := range metas { | |||
| 168 | + | hasMeta[m.Name] = true | |||
| 169 | + | } | |||
| 170 | + | hasDir := make(map[domain.RepoName]bool, len(dirs.Names)) | |||
| 171 | + | for _, name := range dirs.Names { | |||
| 172 | + | hasDir[name] = true | |||
| 173 | + | } | |||
| 174 | + | ||||
| 175 | + | for _, name := range dirs.Names { | |||
| 176 | + | if hasMeta[name] { | |||
| 177 | + | continue | |||
| 178 | + | } | |||
| 179 | + | repo := domain.Repo{ | |||
| 180 | + | Name: name, | |||
| 181 | + | Visibility: domain.VisibilityPrivate, | |||
| 182 | + | CreatedAt: r.clock.Now().UTC(), | |||
| 183 | + | } | |||
| 184 | + | if err := r.meta.Create(ctx, repo); err != nil && !errors.Is(err, domain.ErrAlreadyExists) { | |||
| 185 | + | return rep, fmt.Errorf("register repository %q: %w", name, err) | |||
| 186 | + | } | |||
| 187 | + | log.Warn().Str("repo", name.String()).Msg("registered repository directory without metadata as private") | |||
| 188 | + | rep.Registered = append(rep.Registered, name) | |||
| 189 | + | } | |||
| 190 | + | for _, m := range metas { | |||
| 191 | + | if !hasDir[m.Name] { | |||
| 192 | + | log.Error().Str("repo", m.Name.String()).Msg("repository metadata without a directory, left untouched") | |||
| 193 | + | rep.MissingDirs = append(rep.MissingDirs, m.Name) | |||
| 194 | + | } | |||
| 195 | + | } | |||
| 196 | + | return rep, nil | |||
| 197 | + | } | |||
A internal/app/repos_test.go +329 -0
| @@ -0,0 +1,329 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package app_test | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "errors" | |
| 8 | + | "sync" | |
| 9 | + | "testing" | |
| 10 | + | "time" | |
| 11 | + | ||
| 12 | + | "github.com/stretchr/testify/assert" | |
| 13 | + | "github.com/stretchr/testify/mock" | |
| 14 | + | "github.com/stretchr/testify/require" | |
| 15 | + | ||
| 16 | + | "bnema.dev/ermite/internal/app" | |
| 17 | + | "bnema.dev/ermite/internal/domain" | |
| 18 | + | "bnema.dev/ermite/internal/ports" | |
| 19 | + | "bnema.dev/ermite/internal/ports/mocks" | |
| 20 | + | ) | |
| 21 | + | ||
| 22 | + | var now = time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC) | |
| 23 | + | ||
| 24 | + | type fixture struct { | |
| 25 | + | meta *mocks.RepoMetaStore | |
| 26 | + | git *mocks.GitRepos | |
| 27 | + | clock *mocks.Clock | |
| 28 | + | repos *app.Repos | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | func newFixture(t *testing.T) *fixture { | |
| 32 | + | t.Helper() | |
| 33 | + | f := &fixture{ | |
| 34 | + | meta: mocks.NewRepoMetaStore(t), | |
| 35 | + | git: mocks.NewGitRepos(t), | |
| 36 | + | clock: mocks.NewClock(t), | |
| 37 | + | } | |
| 38 | + | f.clock.EXPECT().Now().Return(now).Maybe() | |
| 39 | + | f.repos = app.NewRepos(f.meta, f.git, f.clock) | |
| 40 | + | return f | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | func name(t *testing.T, s string) domain.RepoName { | |
| 44 | + | t.Helper() | |
| 45 | + | n, err := domain.ParseRepoName(s) | |
| 46 | + | require.NoError(t, err) | |
| 47 | + | return n | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | func TestCreate(t *testing.T) { | |
| 51 | + | ctx := context.Background() | |
| 52 | + | errBoom := errors.New("boom") | |
| 53 | + | ||
| 54 | + | t.Run("success is private by default", func(t *testing.T) { | |
| 55 | + | f := newFixture(t) | |
| 56 | + | n := name(t, "tools") | |
| 57 | + | want := domain.Repo{Name: n, Description: "d", Visibility: domain.VisibilityPrivate, CreatedAt: now} | |
| 58 | + | f.git.EXPECT().Stage(ctx).Return(ports.StagedID("s1"), nil) | |
| 59 | + | f.meta.EXPECT().Create(ctx, want).Return(nil) | |
| 60 | + | f.git.EXPECT().Publish(ctx, ports.StagedID("s1"), n).Return(nil) | |
| 61 | + | ||
| 62 | + | got, err := f.repos.Create(ctx, domain.Root(), app.CreateParams{Name: n, Description: "d"}) | |
| 63 | + | require.NoError(t, err) | |
| 64 | + | assert.Equal(t, want, got) | |
| 65 | + | }) | |
| 66 | + | ||
| 67 | + | t.Run("anonymous is refused before anything runs", func(t *testing.T) { | |
| 68 | + | f := newFixture(t) | |
| 69 | + | _, err := f.repos.Create(ctx, domain.Anonymous(), app.CreateParams{Name: name(t, "tools")}) | |
| 70 | + | assert.ErrorIs(t, err, domain.ErrUnauthenticated) | |
| 71 | + | }) | |
| 72 | + | ||
| 73 | + | t.Run("zero name is invalid", func(t *testing.T) { | |
| 74 | + | f := newFixture(t) | |
| 75 | + | _, err := f.repos.Create(ctx, domain.Root(), app.CreateParams{}) | |
| 76 | + | assert.ErrorIs(t, err, domain.ErrInvalidName) | |
| 77 | + | }) | |
| 78 | + | ||
| 79 | + | t.Run("unknown visibility is invalid", func(t *testing.T) { | |
| 80 | + | f := newFixture(t) | |
| 81 | + | _, err := f.repos.Create(ctx, domain.Root(), app.CreateParams{Name: name(t, "tools"), Visibility: "secret"}) | |
| 82 | + | assert.ErrorIs(t, err, domain.ErrInvalidVisibility) | |
| 83 | + | }) | |
| 84 | + | ||
| 85 | + | t.Run("stage failure stores nothing", func(t *testing.T) { | |
| 86 | + | f := newFixture(t) | |
| 87 | + | f.git.EXPECT().Stage(ctx).Return("", errBoom) | |
| 88 | + | _, err := f.repos.Create(ctx, domain.Root(), app.CreateParams{Name: name(t, "tools")}) | |
| 89 | + | assert.ErrorIs(t, err, errBoom) | |
| 90 | + | }) | |
| 91 | + | ||
| 92 | + | t.Run("existing name discards the staged repository", func(t *testing.T) { | |
| 93 | + | f := newFixture(t) | |
| 94 | + | f.git.EXPECT().Stage(ctx).Return(ports.StagedID("s1"), nil) | |
| 95 | + | f.meta.EXPECT().Create(ctx, mock.Anything).Return(domain.ErrAlreadyExists) | |
| 96 | + | f.git.EXPECT().Discard(mock.Anything, ports.StagedID("s1")).Return(nil) | |
| 97 | + | _, err := f.repos.Create(ctx, domain.Root(), app.CreateParams{Name: name(t, "tools")}) | |
| 98 | + | assert.ErrorIs(t, err, domain.ErrAlreadyExists) | |
| 99 | + | }) | |
| 100 | + | ||
| 101 | + | t.Run("failed publish removes metadata and staging", func(t *testing.T) { | |
| 102 | + | f := newFixture(t) | |
| 103 | + | n := name(t, "tools") | |
| 104 | + | f.git.EXPECT().Stage(ctx).Return(ports.StagedID("s1"), nil) | |
| 105 | + | f.meta.EXPECT().Create(ctx, mock.Anything).Return(nil) | |
| 106 | + | f.git.EXPECT().Publish(ctx, ports.StagedID("s1"), n).Return(errBoom) | |
| 107 | + | f.meta.EXPECT().Delete(mock.Anything, n).Return(nil) | |
| 108 | + | f.git.EXPECT().Discard(mock.Anything, ports.StagedID("s1")).Return(nil) | |
| 109 | + | _, err := f.repos.Create(ctx, domain.Root(), app.CreateParams{Name: n}) | |
| 110 | + | assert.ErrorIs(t, err, errBoom) | |
| 111 | + | }) | |
| 112 | + | ||
| 113 | + | t.Run("cleanup survives a canceled context", func(t *testing.T) { | |
| 114 | + | f := newFixture(t) | |
| 115 | + | n := name(t, "tools") | |
| 116 | + | cctx, cancel := context.WithCancel(ctx) | |
| 117 | + | f.git.EXPECT().Stage(cctx).Return(ports.StagedID("s1"), nil) | |
| 118 | + | f.meta.EXPECT().Create(cctx, mock.Anything).Return(nil) | |
| 119 | + | f.git.EXPECT().Publish(cctx, ports.StagedID("s1"), n). | |
| 120 | + | RunAndReturn(func(context.Context, ports.StagedID, domain.RepoName) error { | |
| 121 | + | cancel() | |
| 122 | + | return errBoom | |
| 123 | + | }) | |
| 124 | + | f.meta.EXPECT().Delete(mock.Anything, n).RunAndReturn(func(c context.Context, _ domain.RepoName) error { | |
| 125 | + | return c.Err() | |
| 126 | + | }) | |
| 127 | + | f.git.EXPECT().Discard(mock.Anything, ports.StagedID("s1")).RunAndReturn(func(c context.Context, _ ports.StagedID) error { | |
| 128 | + | return c.Err() | |
| 129 | + | }) | |
| 130 | + | _, err := f.repos.Create(cctx, domain.Root(), app.CreateParams{Name: n}) | |
| 131 | + | assert.ErrorIs(t, err, errBoom) | |
| 132 | + | }) | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | // A fake store that enforces absence, to model concurrent creates. | |
| 136 | + | type memMeta struct { | |
| 137 | + | mu sync.Mutex | |
| 138 | + | m map[domain.RepoName]domain.Repo | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | func (s *memMeta) create(repo domain.Repo) error { | |
| 142 | + | s.mu.Lock() | |
| 143 | + | defer s.mu.Unlock() | |
| 144 | + | if _, ok := s.m[repo.Name]; ok { | |
| 145 | + | return domain.ErrAlreadyExists | |
| 146 | + | } | |
| 147 | + | s.m[repo.Name] = repo | |
| 148 | + | return nil | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | func TestCreateConcurrentSameName(t *testing.T) { | |
| 152 | + | ctx := context.Background() | |
| 153 | + | n := name(t, "tools") | |
| 154 | + | store := &memMeta{m: map[domain.RepoName]domain.Repo{}} | |
| 155 | + | ||
| 156 | + | f := newFixture(t) | |
| 157 | + | var mu sync.Mutex | |
| 158 | + | staged, discarded, published := 0, 0, 0 | |
| 159 | + | f.git.EXPECT().Stage(mock.Anything).RunAndReturn(func(context.Context) (ports.StagedID, error) { | |
| 160 | + | mu.Lock() | |
| 161 | + | defer mu.Unlock() | |
| 162 | + | staged++ | |
| 163 | + | return ports.StagedID(string(rune('a' + staged))), nil | |
| 164 | + | }) | |
| 165 | + | f.meta.EXPECT().Create(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, r domain.Repo) error { | |
| 166 | + | return store.create(r) | |
| 167 | + | }) | |
| 168 | + | f.git.EXPECT().Publish(mock.Anything, mock.Anything, n).RunAndReturn(func(context.Context, ports.StagedID, domain.RepoName) error { | |
| 169 | + | mu.Lock() | |
| 170 | + | defer mu.Unlock() | |
| 171 | + | published++ | |
| 172 | + | return nil | |
| 173 | + | }) | |
| 174 | + | f.git.EXPECT().Discard(mock.Anything, mock.Anything).RunAndReturn(func(context.Context, ports.StagedID) error { | |
| 175 | + | mu.Lock() | |
| 176 | + | defer mu.Unlock() | |
| 177 | + | discarded++ | |
| 178 | + | return nil | |
| 179 | + | }) | |
| 180 | + | ||
| 181 | + | const workers = 8 | |
| 182 | + | errs := make(chan error, workers) | |
| 183 | + | var wg sync.WaitGroup | |
| 184 | + | for range workers { | |
| 185 | + | wg.Add(1) | |
| 186 | + | go func() { | |
| 187 | + | defer wg.Done() | |
| 188 | + | _, err := f.repos.Create(ctx, domain.Root(), app.CreateParams{Name: n}) | |
| 189 | + | errs <- err | |
| 190 | + | }() | |
| 191 | + | } | |
| 192 | + | wg.Wait() | |
| 193 | + | close(errs) | |
| 194 | + | ||
| 195 | + | ok, exists := 0, 0 | |
| 196 | + | for err := range errs { | |
| 197 | + | switch { | |
| 198 | + | case err == nil: | |
| 199 | + | ok++ | |
| 200 | + | case errors.Is(err, domain.ErrAlreadyExists): | |
| 201 | + | exists++ | |
| 202 | + | default: | |
| 203 | + | t.Fatalf("unexpected error: %v", err) | |
| 204 | + | } | |
| 205 | + | } | |
| 206 | + | assert.Equal(t, 1, ok) | |
| 207 | + | assert.Equal(t, workers-1, exists) | |
| 208 | + | assert.Equal(t, 1, published) | |
| 209 | + | assert.Equal(t, workers-1, discarded) | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | func TestGetAndListAccess(t *testing.T) { | |
| 213 | + | ctx := context.Background() | |
| 214 | + | pub := domain.Repo{Name: name(t, "pub"), Visibility: domain.VisibilityPublic} | |
| 215 | + | priv := domain.Repo{Name: name(t, "priv"), Visibility: domain.VisibilityPrivate} | |
| 216 | + | ||
| 217 | + | tests := []struct { | |
| 218 | + | name string | |
| 219 | + | principal domain.Principal | |
| 220 | + | get domain.Repo | |
| 221 | + | wantErr error | |
| 222 | + | }{ | |
| 223 | + | {"anonymous reads public", domain.Anonymous(), pub, nil}, | |
| 224 | + | {"anonymous cannot see private", domain.Anonymous(), priv, domain.ErrNotFound}, | |
| 225 | + | {"root reads public", domain.Root(), pub, nil}, | |
| 226 | + | {"root reads private", domain.Root(), priv, nil}, | |
| 227 | + | } | |
| 228 | + | for _, tt := range tests { | |
| 229 | + | t.Run("get/"+tt.name, func(t *testing.T) { | |
| 230 | + | f := newFixture(t) | |
| 231 | + | f.meta.EXPECT().Get(ctx, tt.get.Name).Return(tt.get, nil) | |
| 232 | + | got, err := f.repos.Get(ctx, tt.principal, tt.get.Name) | |
| 233 | + | if tt.wantErr != nil { | |
| 234 | + | assert.ErrorIs(t, err, tt.wantErr) | |
| 235 | + | assert.Zero(t, got) | |
| 236 | + | return | |
| 237 | + | } | |
| 238 | + | require.NoError(t, err) | |
| 239 | + | assert.Equal(t, tt.get, got) | |
| 240 | + | }) | |
| 241 | + | } | |
| 242 | + | ||
| 243 | + | t.Run("get missing is not found", func(t *testing.T) { | |
| 244 | + | f := newFixture(t) | |
| 245 | + | f.meta.EXPECT().Get(ctx, pub.Name).Return(domain.Repo{}, domain.ErrNotFound) | |
| 246 | + | _, err := f.repos.Get(ctx, domain.Root(), pub.Name) | |
| 247 | + | assert.ErrorIs(t, err, domain.ErrNotFound) | |
| 248 | + | }) | |
| 249 | + | ||
| 250 | + | t.Run("private missing and hidden look the same", func(t *testing.T) { | |
| 251 | + | f := newFixture(t) | |
| 252 | + | f.meta.EXPECT().Get(ctx, priv.Name).Return(priv, nil) | |
| 253 | + | _, errHidden := f.repos.Get(ctx, domain.Anonymous(), priv.Name) | |
| 254 | + | f.meta.EXPECT().Get(ctx, name(t, "gone")).Return(domain.Repo{}, domain.ErrNotFound) | |
| 255 | + | _, errMissing := f.repos.Get(ctx, domain.Anonymous(), name(t, "gone")) | |
| 256 | + | assert.Equal(t, errMissing, errHidden) | |
| 257 | + | }) | |
| 258 | + | ||
| 259 | + | listTests := []struct { | |
| 260 | + | name string | |
| 261 | + | principal domain.Principal | |
| 262 | + | want []domain.Repo | |
| 263 | + | }{ | |
| 264 | + | {"anonymous lists public only", domain.Anonymous(), []domain.Repo{pub}}, | |
| 265 | + | {"root lists all", domain.Root(), []domain.Repo{priv, pub}}, | |
| 266 | + | } | |
| 267 | + | for _, tt := range listTests { | |
| 268 | + | t.Run("list/"+tt.name, func(t *testing.T) { | |
| 269 | + | f := newFixture(t) | |
| 270 | + | f.meta.EXPECT().List(ctx).Return([]domain.Repo{priv, pub}, nil) | |
| 271 | + | got, err := f.repos.List(ctx, tt.principal) | |
| 272 | + | require.NoError(t, err) | |
| 273 | + | assert.Equal(t, tt.want, got) | |
| 274 | + | }) | |
| 275 | + | } | |
| 276 | + | ||
| 277 | + | t.Run("list error", func(t *testing.T) { | |
| 278 | + | f := newFixture(t) | |
| 279 | + | boom := errors.New("boom") | |
| 280 | + | f.meta.EXPECT().List(ctx).Return(nil, boom) | |
| 281 | + | _, err := f.repos.List(ctx, domain.Root()) | |
| 282 | + | assert.ErrorIs(t, err, boom) | |
| 283 | + | }) | |
| 284 | + | } | |
| 285 | + | ||
| 286 | + | func TestReconcile(t *testing.T) { | |
| 287 | + | ctx := context.Background() | |
| 288 | + | ||
| 289 | + | t.Run("registers orphan dir as private, reports missing dir, skips invalid", func(t *testing.T) { | |
| 290 | + | f := newFixture(t) | |
| 291 | + | orphan, kept, ghost := name(t, "orphan"), name(t, "kept"), name(t, "ghost") | |
| 292 | + | f.git.EXPECT().PurgeStaging(ctx).Return(nil) | |
| 293 | + | f.git.EXPECT().List(ctx).Return(ports.RepoDirs{ | |
| 294 | + | Names: []domain.RepoName{orphan, kept}, | |
| 295 | + | Invalid: []string{"Bad Name.git"}, | |
| 296 | + | }, nil) | |
| 297 | + | f.meta.EXPECT().List(ctx).Return([]domain.Repo{ | |
| 298 | + | {Name: ghost, Visibility: domain.VisibilityPublic}, | |
| 299 | + | {Name: kept, Visibility: domain.VisibilityPublic}, | |
| 300 | + | }, nil) | |
| 301 | + | f.meta.EXPECT().Create(ctx, domain.Repo{Name: orphan, Visibility: domain.VisibilityPrivate, CreatedAt: now}).Return(nil) | |
| 302 | + | ||
| 303 | + | rep, err := f.repos.Reconcile(ctx) | |
| 304 | + | require.NoError(t, err) | |
| 305 | + | assert.Equal(t, []domain.RepoName{orphan}, rep.Registered) | |
| 306 | + | assert.Equal(t, []domain.RepoName{ghost}, rep.MissingDirs) | |
| 307 | + | assert.Equal(t, []string{"Bad Name.git"}, rep.InvalidDirs) | |
| 308 | + | }) | |
| 309 | + | ||
| 310 | + | t.Run("purge failure stops", func(t *testing.T) { | |
| 311 | + | f := newFixture(t) | |
| 312 | + | boom := errors.New("boom") | |
| 313 | + | f.git.EXPECT().PurgeStaging(ctx).Return(boom) | |
| 314 | + | _, err := f.repos.Reconcile(ctx) | |
| 315 | + | assert.ErrorIs(t, err, boom) | |
| 316 | + | }) | |
| 317 | + | ||
| 318 | + | t.Run("register failure stops", func(t *testing.T) { | |
| 319 | + | f := newFixture(t) | |
| 320 | + | boom := errors.New("boom") | |
| 321 | + | orphan := name(t, "orphan") | |
| 322 | + | f.git.EXPECT().PurgeStaging(ctx).Return(nil) | |
| 323 | + | f.git.EXPECT().List(ctx).Return(ports.RepoDirs{Names: []domain.RepoName{orphan}}, nil) | |
| 324 | + | f.meta.EXPECT().List(ctx).Return(nil, nil) | |
| 325 | + | f.meta.EXPECT().Create(ctx, mock.Anything).Return(boom) | |
| 326 | + | _, err := f.repos.Reconcile(ctx) | |
| 327 | + | assert.ErrorIs(t, err, boom) | |
| 328 | + | }) | |
| 329 | + | } | |
| @@ -0,0 +1,329 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package app_test | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "errors" | |||
| 8 | + | "sync" | |||
| 9 | + | "testing" | |||
| 10 | + | "time" | |||
| 11 | + | ||||
| 12 | + | "github.com/stretchr/testify/assert" | |||
| 13 | + | "github.com/stretchr/testify/mock" | |||
| 14 | + | "github.com/stretchr/testify/require" | |||
| 15 | + | ||||
| 16 | + | "bnema.dev/ermite/internal/app" | |||
| 17 | + | "bnema.dev/ermite/internal/domain" | |||
| 18 | + | "bnema.dev/ermite/internal/ports" | |||
| 19 | + | "bnema.dev/ermite/internal/ports/mocks" | |||
| 20 | + | ) | |||
| 21 | + | ||||
| 22 | + | var now = time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC) | |||
| 23 | + | ||||
| 24 | + | type fixture struct { | |||
| 25 | + | meta *mocks.RepoMetaStore | |||
| 26 | + | git *mocks.GitRepos | |||
| 27 | + | clock *mocks.Clock | |||
| 28 | + | repos *app.Repos | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | func newFixture(t *testing.T) *fixture { | |||
| 32 | + | t.Helper() | |||
| 33 | + | f := &fixture{ | |||
| 34 | + | meta: mocks.NewRepoMetaStore(t), | |||
| 35 | + | git: mocks.NewGitRepos(t), | |||
| 36 | + | clock: mocks.NewClock(t), | |||
| 37 | + | } | |||
| 38 | + | f.clock.EXPECT().Now().Return(now).Maybe() | |||
| 39 | + | f.repos = app.NewRepos(f.meta, f.git, f.clock) | |||
| 40 | + | return f | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | func name(t *testing.T, s string) domain.RepoName { | |||
| 44 | + | t.Helper() | |||
| 45 | + | n, err := domain.ParseRepoName(s) | |||
| 46 | + | require.NoError(t, err) | |||
| 47 | + | return n | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | func TestCreate(t *testing.T) { | |||
| 51 | + | ctx := context.Background() | |||
| 52 | + | errBoom := errors.New("boom") | |||
| 53 | + | ||||
| 54 | + | t.Run("success is private by default", func(t *testing.T) { | |||
| 55 | + | f := newFixture(t) | |||
| 56 | + | n := name(t, "tools") | |||
| 57 | + | want := domain.Repo{Name: n, Description: "d", Visibility: domain.VisibilityPrivate, CreatedAt: now} | |||
| 58 | + | f.git.EXPECT().Stage(ctx).Return(ports.StagedID("s1"), nil) | |||
| 59 | + | f.meta.EXPECT().Create(ctx, want).Return(nil) | |||
| 60 | + | f.git.EXPECT().Publish(ctx, ports.StagedID("s1"), n).Return(nil) | |||
| 61 | + | ||||
| 62 | + | got, err := f.repos.Create(ctx, domain.Root(), app.CreateParams{Name: n, Description: "d"}) | |||
| 63 | + | require.NoError(t, err) | |||
| 64 | + | assert.Equal(t, want, got) | |||
| 65 | + | }) | |||
| 66 | + | ||||
| 67 | + | t.Run("anonymous is refused before anything runs", func(t *testing.T) { | |||
| 68 | + | f := newFixture(t) | |||
| 69 | + | _, err := f.repos.Create(ctx, domain.Anonymous(), app.CreateParams{Name: name(t, "tools")}) | |||
| 70 | + | assert.ErrorIs(t, err, domain.ErrUnauthenticated) | |||
| 71 | + | }) | |||
| 72 | + | ||||
| 73 | + | t.Run("zero name is invalid", func(t *testing.T) { | |||
| 74 | + | f := newFixture(t) | |||
| 75 | + | _, err := f.repos.Create(ctx, domain.Root(), app.CreateParams{}) | |||
| 76 | + | assert.ErrorIs(t, err, domain.ErrInvalidName) | |||
| 77 | + | }) | |||
| 78 | + | ||||
| 79 | + | t.Run("unknown visibility is invalid", func(t *testing.T) { | |||
| 80 | + | f := newFixture(t) | |||
| 81 | + | _, err := f.repos.Create(ctx, domain.Root(), app.CreateParams{Name: name(t, "tools"), Visibility: "secret"}) | |||
| 82 | + | assert.ErrorIs(t, err, domain.ErrInvalidVisibility) | |||
| 83 | + | }) | |||
| 84 | + | ||||
| 85 | + | t.Run("stage failure stores nothing", func(t *testing.T) { | |||
| 86 | + | f := newFixture(t) | |||
| 87 | + | f.git.EXPECT().Stage(ctx).Return("", errBoom) | |||
| 88 | + | _, err := f.repos.Create(ctx, domain.Root(), app.CreateParams{Name: name(t, "tools")}) | |||
| 89 | + | assert.ErrorIs(t, err, errBoom) | |||
| 90 | + | }) | |||
| 91 | + | ||||
| 92 | + | t.Run("existing name discards the staged repository", func(t *testing.T) { | |||
| 93 | + | f := newFixture(t) | |||
| 94 | + | f.git.EXPECT().Stage(ctx).Return(ports.StagedID("s1"), nil) | |||
| 95 | + | f.meta.EXPECT().Create(ctx, mock.Anything).Return(domain.ErrAlreadyExists) | |||
| 96 | + | f.git.EXPECT().Discard(mock.Anything, ports.StagedID("s1")).Return(nil) | |||
| 97 | + | _, err := f.repos.Create(ctx, domain.Root(), app.CreateParams{Name: name(t, "tools")}) | |||
| 98 | + | assert.ErrorIs(t, err, domain.ErrAlreadyExists) | |||
| 99 | + | }) | |||
| 100 | + | ||||
| 101 | + | t.Run("failed publish removes metadata and staging", func(t *testing.T) { | |||
| 102 | + | f := newFixture(t) | |||
| 103 | + | n := name(t, "tools") | |||
| 104 | + | f.git.EXPECT().Stage(ctx).Return(ports.StagedID("s1"), nil) | |||
| 105 | + | f.meta.EXPECT().Create(ctx, mock.Anything).Return(nil) | |||
| 106 | + | f.git.EXPECT().Publish(ctx, ports.StagedID("s1"), n).Return(errBoom) | |||
| 107 | + | f.meta.EXPECT().Delete(mock.Anything, n).Return(nil) | |||
| 108 | + | f.git.EXPECT().Discard(mock.Anything, ports.StagedID("s1")).Return(nil) | |||
| 109 | + | _, err := f.repos.Create(ctx, domain.Root(), app.CreateParams{Name: n}) | |||
| 110 | + | assert.ErrorIs(t, err, errBoom) | |||
| 111 | + | }) | |||
| 112 | + | ||||
| 113 | + | t.Run("cleanup survives a canceled context", func(t *testing.T) { | |||
| 114 | + | f := newFixture(t) | |||
| 115 | + | n := name(t, "tools") | |||
| 116 | + | cctx, cancel := context.WithCancel(ctx) | |||
| 117 | + | f.git.EXPECT().Stage(cctx).Return(ports.StagedID("s1"), nil) | |||
| 118 | + | f.meta.EXPECT().Create(cctx, mock.Anything).Return(nil) | |||
| 119 | + | f.git.EXPECT().Publish(cctx, ports.StagedID("s1"), n). | |||
| 120 | + | RunAndReturn(func(context.Context, ports.StagedID, domain.RepoName) error { | |||
| 121 | + | cancel() | |||
| 122 | + | return errBoom | |||
| 123 | + | }) | |||
| 124 | + | f.meta.EXPECT().Delete(mock.Anything, n).RunAndReturn(func(c context.Context, _ domain.RepoName) error { | |||
| 125 | + | return c.Err() | |||
| 126 | + | }) | |||
| 127 | + | f.git.EXPECT().Discard(mock.Anything, ports.StagedID("s1")).RunAndReturn(func(c context.Context, _ ports.StagedID) error { | |||
| 128 | + | return c.Err() | |||
| 129 | + | }) | |||
| 130 | + | _, err := f.repos.Create(cctx, domain.Root(), app.CreateParams{Name: n}) | |||
| 131 | + | assert.ErrorIs(t, err, errBoom) | |||
| 132 | + | }) | |||
| 133 | + | } | |||
| 134 | + | ||||
| 135 | + | // A fake store that enforces absence, to model concurrent creates. | |||
| 136 | + | type memMeta struct { | |||
| 137 | + | mu sync.Mutex | |||
| 138 | + | m map[domain.RepoName]domain.Repo | |||
| 139 | + | } | |||
| 140 | + | ||||
| 141 | + | func (s *memMeta) create(repo domain.Repo) error { | |||
| 142 | + | s.mu.Lock() | |||
| 143 | + | defer s.mu.Unlock() | |||
| 144 | + | if _, ok := s.m[repo.Name]; ok { | |||
| 145 | + | return domain.ErrAlreadyExists | |||
| 146 | + | } | |||
| 147 | + | s.m[repo.Name] = repo | |||
| 148 | + | return nil | |||
| 149 | + | } | |||
| 150 | + | ||||
| 151 | + | func TestCreateConcurrentSameName(t *testing.T) { | |||
| 152 | + | ctx := context.Background() | |||
| 153 | + | n := name(t, "tools") | |||
| 154 | + | store := &memMeta{m: map[domain.RepoName]domain.Repo{}} | |||
| 155 | + | ||||
| 156 | + | f := newFixture(t) | |||
| 157 | + | var mu sync.Mutex | |||
| 158 | + | staged, discarded, published := 0, 0, 0 | |||
| 159 | + | f.git.EXPECT().Stage(mock.Anything).RunAndReturn(func(context.Context) (ports.StagedID, error) { | |||
| 160 | + | mu.Lock() | |||
| 161 | + | defer mu.Unlock() | |||
| 162 | + | staged++ | |||
| 163 | + | return ports.StagedID(string(rune('a' + staged))), nil | |||
| 164 | + | }) | |||
| 165 | + | f.meta.EXPECT().Create(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, r domain.Repo) error { | |||
| 166 | + | return store.create(r) | |||
| 167 | + | }) | |||
| 168 | + | f.git.EXPECT().Publish(mock.Anything, mock.Anything, n).RunAndReturn(func(context.Context, ports.StagedID, domain.RepoName) error { | |||
| 169 | + | mu.Lock() | |||
| 170 | + | defer mu.Unlock() | |||
| 171 | + | published++ | |||
| 172 | + | return nil | |||
| 173 | + | }) | |||
| 174 | + | f.git.EXPECT().Discard(mock.Anything, mock.Anything).RunAndReturn(func(context.Context, ports.StagedID) error { | |||
| 175 | + | mu.Lock() | |||
| 176 | + | defer mu.Unlock() | |||
| 177 | + | discarded++ | |||
| 178 | + | return nil | |||
| 179 | + | }) | |||
| 180 | + | ||||
| 181 | + | const workers = 8 | |||
| 182 | + | errs := make(chan error, workers) | |||
| 183 | + | var wg sync.WaitGroup | |||
| 184 | + | for range workers { | |||
| 185 | + | wg.Add(1) | |||
| 186 | + | go func() { | |||
| 187 | + | defer wg.Done() | |||
| 188 | + | _, err := f.repos.Create(ctx, domain.Root(), app.CreateParams{Name: n}) | |||
| 189 | + | errs <- err | |||
| 190 | + | }() | |||
| 191 | + | } | |||
| 192 | + | wg.Wait() | |||
| 193 | + | close(errs) | |||
| 194 | + | ||||
| 195 | + | ok, exists := 0, 0 | |||
| 196 | + | for err := range errs { | |||
| 197 | + | switch { | |||
| 198 | + | case err == nil: | |||
| 199 | + | ok++ | |||
| 200 | + | case errors.Is(err, domain.ErrAlreadyExists): | |||
| 201 | + | exists++ | |||
| 202 | + | default: | |||
| 203 | + | t.Fatalf("unexpected error: %v", err) | |||
| 204 | + | } | |||
| 205 | + | } | |||
| 206 | + | assert.Equal(t, 1, ok) | |||
| 207 | + | assert.Equal(t, workers-1, exists) | |||
| 208 | + | assert.Equal(t, 1, published) | |||
| 209 | + | assert.Equal(t, workers-1, discarded) | |||
| 210 | + | } | |||
| 211 | + | ||||
| 212 | + | func TestGetAndListAccess(t *testing.T) { | |||
| 213 | + | ctx := context.Background() | |||
| 214 | + | pub := domain.Repo{Name: name(t, "pub"), Visibility: domain.VisibilityPublic} | |||
| 215 | + | priv := domain.Repo{Name: name(t, "priv"), Visibility: domain.VisibilityPrivate} | |||
| 216 | + | ||||
| 217 | + | tests := []struct { | |||
| 218 | + | name string | |||
| 219 | + | principal domain.Principal | |||
| 220 | + | get domain.Repo | |||
| 221 | + | wantErr error | |||
| 222 | + | }{ | |||
| 223 | + | {"anonymous reads public", domain.Anonymous(), pub, nil}, | |||
| 224 | + | {"anonymous cannot see private", domain.Anonymous(), priv, domain.ErrNotFound}, | |||
| 225 | + | {"root reads public", domain.Root(), pub, nil}, | |||
| 226 | + | {"root reads private", domain.Root(), priv, nil}, | |||
| 227 | + | } | |||
| 228 | + | for _, tt := range tests { | |||
| 229 | + | t.Run("get/"+tt.name, func(t *testing.T) { | |||
| 230 | + | f := newFixture(t) | |||
| 231 | + | f.meta.EXPECT().Get(ctx, tt.get.Name).Return(tt.get, nil) | |||
| 232 | + | got, err := f.repos.Get(ctx, tt.principal, tt.get.Name) | |||
| 233 | + | if tt.wantErr != nil { | |||
| 234 | + | assert.ErrorIs(t, err, tt.wantErr) | |||
| 235 | + | assert.Zero(t, got) | |||
| 236 | + | return | |||
| 237 | + | } | |||
| 238 | + | require.NoError(t, err) | |||
| 239 | + | assert.Equal(t, tt.get, got) | |||
| 240 | + | }) | |||
| 241 | + | } | |||
| 242 | + | ||||
| 243 | + | t.Run("get missing is not found", func(t *testing.T) { | |||
| 244 | + | f := newFixture(t) | |||
| 245 | + | f.meta.EXPECT().Get(ctx, pub.Name).Return(domain.Repo{}, domain.ErrNotFound) | |||
| 246 | + | _, err := f.repos.Get(ctx, domain.Root(), pub.Name) | |||
| 247 | + | assert.ErrorIs(t, err, domain.ErrNotFound) | |||
| 248 | + | }) | |||
| 249 | + | ||||
| 250 | + | t.Run("private missing and hidden look the same", func(t *testing.T) { | |||
| 251 | + | f := newFixture(t) | |||
| 252 | + | f.meta.EXPECT().Get(ctx, priv.Name).Return(priv, nil) | |||
| 253 | + | _, errHidden := f.repos.Get(ctx, domain.Anonymous(), priv.Name) | |||
| 254 | + | f.meta.EXPECT().Get(ctx, name(t, "gone")).Return(domain.Repo{}, domain.ErrNotFound) | |||
| 255 | + | _, errMissing := f.repos.Get(ctx, domain.Anonymous(), name(t, "gone")) | |||
| 256 | + | assert.Equal(t, errMissing, errHidden) | |||
| 257 | + | }) | |||
| 258 | + | ||||
| 259 | + | listTests := []struct { | |||
| 260 | + | name string | |||
| 261 | + | principal domain.Principal | |||
| 262 | + | want []domain.Repo | |||
| 263 | + | }{ | |||
| 264 | + | {"anonymous lists public only", domain.Anonymous(), []domain.Repo{pub}}, | |||
| 265 | + | {"root lists all", domain.Root(), []domain.Repo{priv, pub}}, | |||
| 266 | + | } | |||
| 267 | + | for _, tt := range listTests { | |||
| 268 | + | t.Run("list/"+tt.name, func(t *testing.T) { | |||
| 269 | + | f := newFixture(t) | |||
| 270 | + | f.meta.EXPECT().List(ctx).Return([]domain.Repo{priv, pub}, nil) | |||
| 271 | + | got, err := f.repos.List(ctx, tt.principal) | |||
| 272 | + | require.NoError(t, err) | |||
| 273 | + | assert.Equal(t, tt.want, got) | |||
| 274 | + | }) | |||
| 275 | + | } | |||
| 276 | + | ||||
| 277 | + | t.Run("list error", func(t *testing.T) { | |||
| 278 | + | f := newFixture(t) | |||
| 279 | + | boom := errors.New("boom") | |||
| 280 | + | f.meta.EXPECT().List(ctx).Return(nil, boom) | |||
| 281 | + | _, err := f.repos.List(ctx, domain.Root()) | |||
| 282 | + | assert.ErrorIs(t, err, boom) | |||
| 283 | + | }) | |||
| 284 | + | } | |||
| 285 | + | ||||
| 286 | + | func TestReconcile(t *testing.T) { | |||
| 287 | + | ctx := context.Background() | |||
| 288 | + | ||||
| 289 | + | t.Run("registers orphan dir as private, reports missing dir, skips invalid", func(t *testing.T) { | |||
| 290 | + | f := newFixture(t) | |||
| 291 | + | orphan, kept, ghost := name(t, "orphan"), name(t, "kept"), name(t, "ghost") | |||
| 292 | + | f.git.EXPECT().PurgeStaging(ctx).Return(nil) | |||
| 293 | + | f.git.EXPECT().List(ctx).Return(ports.RepoDirs{ | |||
| 294 | + | Names: []domain.RepoName{orphan, kept}, | |||
| 295 | + | Invalid: []string{"Bad Name.git"}, | |||
| 296 | + | }, nil) | |||
| 297 | + | f.meta.EXPECT().List(ctx).Return([]domain.Repo{ | |||
| 298 | + | {Name: ghost, Visibility: domain.VisibilityPublic}, | |||
| 299 | + | {Name: kept, Visibility: domain.VisibilityPublic}, | |||
| 300 | + | }, nil) | |||
| 301 | + | f.meta.EXPECT().Create(ctx, domain.Repo{Name: orphan, Visibility: domain.VisibilityPrivate, CreatedAt: now}).Return(nil) | |||
| 302 | + | ||||
| 303 | + | rep, err := f.repos.Reconcile(ctx) | |||
| 304 | + | require.NoError(t, err) | |||
| 305 | + | assert.Equal(t, []domain.RepoName{orphan}, rep.Registered) | |||
| 306 | + | assert.Equal(t, []domain.RepoName{ghost}, rep.MissingDirs) | |||
| 307 | + | assert.Equal(t, []string{"Bad Name.git"}, rep.InvalidDirs) | |||
| 308 | + | }) | |||
| 309 | + | ||||
| 310 | + | t.Run("purge failure stops", func(t *testing.T) { | |||
| 311 | + | f := newFixture(t) | |||
| 312 | + | boom := errors.New("boom") | |||
| 313 | + | f.git.EXPECT().PurgeStaging(ctx).Return(boom) | |||
| 314 | + | _, err := f.repos.Reconcile(ctx) | |||
| 315 | + | assert.ErrorIs(t, err, boom) | |||
| 316 | + | }) | |||
| 317 | + | ||||
| 318 | + | t.Run("register failure stops", func(t *testing.T) { | |||
| 319 | + | f := newFixture(t) | |||
| 320 | + | boom := errors.New("boom") | |||
| 321 | + | orphan := name(t, "orphan") | |||
| 322 | + | f.git.EXPECT().PurgeStaging(ctx).Return(nil) | |||
| 323 | + | f.git.EXPECT().List(ctx).Return(ports.RepoDirs{Names: []domain.RepoName{orphan}}, nil) | |||
| 324 | + | f.meta.EXPECT().List(ctx).Return(nil, nil) | |||
| 325 | + | f.meta.EXPECT().Create(ctx, mock.Anything).Return(boom) | |||
| 326 | + | _, err := f.repos.Reconcile(ctx) | |||
| 327 | + | assert.ErrorIs(t, err, boom) | |||
| 328 | + | }) | |||
| 329 | + | } | |||