feat(domain): add repository name, visibility and principal
- commit
- f7f6672582087b15c1f07a95f76d5f2f6a43d9a8
- author
- brice · · 2026-10-10 06:09 UTC
- parent
- 0ed56a9
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
5 files · +315 -0
A internal/domain/errors.go +21 -0
| @@ -0,0 +1,21 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package domain | |
| 4 | + | ||
| 5 | + | import "errors" | |
| 6 | + | ||
| 7 | + | // Sentinel errors of the domain. Callers match them with errors.Is. | |
| 8 | + | var ( | |
| 9 | + | // ErrNotFound means the entity does not exist or is hidden from the caller. | |
| 10 | + | ErrNotFound = errors.New("not found") | |
| 11 | + | // ErrInvalidName means a repository name breaks the naming rules. | |
| 12 | + | ErrInvalidName = errors.New("invalid repository name") | |
| 13 | + | // ErrInvalidVisibility means a visibility value is neither public nor private. | |
| 14 | + | ErrInvalidVisibility = errors.New("invalid visibility") | |
| 15 | + | // ErrAlreadyExists means the entity to create already exists. | |
| 16 | + | ErrAlreadyExists = errors.New("already exists") | |
| 17 | + | // ErrUnauthenticated means the operation needs credentials the caller lacks. | |
| 18 | + | ErrUnauthenticated = errors.New("unauthenticated") | |
| 19 | + | // ErrConflict means a concurrent change prevented the operation. | |
| 20 | + | ErrConflict = errors.New("conflict") | |
| 21 | + | ) | |
| @@ -0,0 +1,21 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package domain | |||
| 4 | + | ||||
| 5 | + | import "errors" | |||
| 6 | + | ||||
| 7 | + | // Sentinel errors of the domain. Callers match them with errors.Is. | |||
| 8 | + | var ( | |||
| 9 | + | // ErrNotFound means the entity does not exist or is hidden from the caller. | |||
| 10 | + | ErrNotFound = errors.New("not found") | |||
| 11 | + | // ErrInvalidName means a repository name breaks the naming rules. | |||
| 12 | + | ErrInvalidName = errors.New("invalid repository name") | |||
| 13 | + | // ErrInvalidVisibility means a visibility value is neither public nor private. | |||
| 14 | + | ErrInvalidVisibility = errors.New("invalid visibility") | |||
| 15 | + | // ErrAlreadyExists means the entity to create already exists. | |||
| 16 | + | ErrAlreadyExists = errors.New("already exists") | |||
| 17 | + | // ErrUnauthenticated means the operation needs credentials the caller lacks. | |||
| 18 | + | ErrUnauthenticated = errors.New("unauthenticated") | |||
| 19 | + | // ErrConflict means a concurrent change prevented the operation. | |||
| 20 | + | ErrConflict = errors.New("conflict") | |||
| 21 | + | ) | |||
A internal/domain/name.go +103 -0
| @@ -0,0 +1,103 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package domain | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "fmt" | |
| 7 | + | "strings" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // MaxRepoNameLen is the longest repository name, in characters. | |
| 11 | + | const MaxRepoNameLen = 100 | |
| 12 | + | ||
| 13 | + | // gitSuffix is the suffix of a Git URL and of a repository directory. | |
| 14 | + | const gitSuffix = ".git" | |
| 15 | + | ||
| 16 | + | // reservedNames cannot name a repository: they are routes of the server. | |
| 17 | + | var reservedNames = map[string]bool{ | |
| 18 | + | "-": true, | |
| 19 | + | "robots.txt": true, | |
| 20 | + | "favicon.ico": true, | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | // RepoName is a validated repository name. Only ParseRepoName and | |
| 24 | + | // ParseRepoPath build a non-zero value; the zero value is invalid. | |
| 25 | + | type RepoName struct{ s string } | |
| 26 | + | ||
| 27 | + | // String returns the name. | |
| 28 | + | func (n RepoName) String() string { return n.s } | |
| 29 | + | ||
| 30 | + | // IsZero reports whether n is the invalid zero value. | |
| 31 | + | func (n RepoName) IsZero() bool { return n.s == "" } | |
| 32 | + | ||
| 33 | + | // ParseRepoName validates s as a repository name. It does not strip any | |
| 34 | + | // ".git" suffix: a name ending in ".git" is invalid. | |
| 35 | + | // | |
| 36 | + | // The error matches ErrInvalidName and is an *InvalidNameError. | |
| 37 | + | func ParseRepoName(s string) (RepoName, error) { | |
| 38 | + | reason := nameProblem(s) | |
| 39 | + | if reason == "" { | |
| 40 | + | return RepoName{s: s}, nil | |
| 41 | + | } | |
| 42 | + | e := &InvalidNameError{Name: s, Reason: reason} | |
| 43 | + | if lower := strings.ToLower(s); lower != s && nameProblem(lower) == "" { | |
| 44 | + | e.Suggestion = lower | |
| 45 | + | } | |
| 46 | + | return RepoName{}, e | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | // ParseRepoPath parses the repository segment of a URL path. It strips one | |
| 50 | + | // trailing ".git" before validating, so "tools.git" names "tools". | |
| 51 | + | func ParseRepoPath(segment string) (RepoName, error) { | |
| 52 | + | return ParseRepoName(strings.TrimSuffix(segment, gitSuffix)) | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | // InvalidNameError describes why a repository name is invalid. It matches | |
| 56 | + | // ErrInvalidName. | |
| 57 | + | type InvalidNameError struct { | |
| 58 | + | Name string | |
| 59 | + | Reason string | |
| 60 | + | Suggestion string // a valid alternative, empty when there is none | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | // Error implements error. | |
| 64 | + | func (e *InvalidNameError) Error() string { | |
| 65 | + | msg := fmt.Sprintf("invalid repository name %q: %s", e.Name, e.Reason) | |
| 66 | + | if e.Suggestion != "" { | |
| 67 | + | msg += fmt.Sprintf(" (try %q)", e.Suggestion) | |
| 68 | + | } | |
| 69 | + | return msg | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | // Unwrap makes errors.Is(err, ErrInvalidName) true. | |
| 73 | + | func (e *InvalidNameError) Unwrap() error { return ErrInvalidName } | |
| 74 | + | ||
| 75 | + | // nameProblem returns why s is not a valid name, or "" when it is valid. | |
| 76 | + | func nameProblem(s string) string { | |
| 77 | + | if s == "" { | |
| 78 | + | return "name is empty" | |
| 79 | + | } | |
| 80 | + | if len(s) > MaxRepoNameLen { | |
| 81 | + | return fmt.Sprintf("name is longer than %d characters", MaxRepoNameLen) | |
| 82 | + | } | |
| 83 | + | for _, r := range s { | |
| 84 | + | switch { | |
| 85 | + | case r >= 'A' && r <= 'Z': | |
| 86 | + | return "uppercase letters are not allowed" | |
| 87 | + | case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '.', r == '_', r == '-': | |
| 88 | + | default: | |
| 89 | + | return fmt.Sprintf("character %q is not allowed (use a-z, 0-9, '.', '_', '-')", r) | |
| 90 | + | } | |
| 91 | + | } | |
| 92 | + | switch { | |
| 93 | + | case strings.HasPrefix(s, ".") || strings.HasSuffix(s, "."): | |
| 94 | + | return "name must not start or end with '.'" | |
| 95 | + | case strings.Contains(s, ".."): | |
| 96 | + | return "name must not contain '..'" | |
| 97 | + | case strings.HasSuffix(s, gitSuffix): | |
| 98 | + | return "name must not end in '.git'" | |
| 99 | + | case reservedNames[s]: | |
| 100 | + | return "name is reserved" | |
| 101 | + | } | |
| 102 | + | return "" | |
| 103 | + | } | |
| @@ -0,0 +1,103 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package domain | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "fmt" | |||
| 7 | + | "strings" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | // MaxRepoNameLen is the longest repository name, in characters. | |||
| 11 | + | const MaxRepoNameLen = 100 | |||
| 12 | + | ||||
| 13 | + | // gitSuffix is the suffix of a Git URL and of a repository directory. | |||
| 14 | + | const gitSuffix = ".git" | |||
| 15 | + | ||||
| 16 | + | // reservedNames cannot name a repository: they are routes of the server. | |||
| 17 | + | var reservedNames = map[string]bool{ | |||
| 18 | + | "-": true, | |||
| 19 | + | "robots.txt": true, | |||
| 20 | + | "favicon.ico": true, | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | // RepoName is a validated repository name. Only ParseRepoName and | |||
| 24 | + | // ParseRepoPath build a non-zero value; the zero value is invalid. | |||
| 25 | + | type RepoName struct{ s string } | |||
| 26 | + | ||||
| 27 | + | // String returns the name. | |||
| 28 | + | func (n RepoName) String() string { return n.s } | |||
| 29 | + | ||||
| 30 | + | // IsZero reports whether n is the invalid zero value. | |||
| 31 | + | func (n RepoName) IsZero() bool { return n.s == "" } | |||
| 32 | + | ||||
| 33 | + | // ParseRepoName validates s as a repository name. It does not strip any | |||
| 34 | + | // ".git" suffix: a name ending in ".git" is invalid. | |||
| 35 | + | // | |||
| 36 | + | // The error matches ErrInvalidName and is an *InvalidNameError. | |||
| 37 | + | func ParseRepoName(s string) (RepoName, error) { | |||
| 38 | + | reason := nameProblem(s) | |||
| 39 | + | if reason == "" { | |||
| 40 | + | return RepoName{s: s}, nil | |||
| 41 | + | } | |||
| 42 | + | e := &InvalidNameError{Name: s, Reason: reason} | |||
| 43 | + | if lower := strings.ToLower(s); lower != s && nameProblem(lower) == "" { | |||
| 44 | + | e.Suggestion = lower | |||
| 45 | + | } | |||
| 46 | + | return RepoName{}, e | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | // ParseRepoPath parses the repository segment of a URL path. It strips one | |||
| 50 | + | // trailing ".git" before validating, so "tools.git" names "tools". | |||
| 51 | + | func ParseRepoPath(segment string) (RepoName, error) { | |||
| 52 | + | return ParseRepoName(strings.TrimSuffix(segment, gitSuffix)) | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | // InvalidNameError describes why a repository name is invalid. It matches | |||
| 56 | + | // ErrInvalidName. | |||
| 57 | + | type InvalidNameError struct { | |||
| 58 | + | Name string | |||
| 59 | + | Reason string | |||
| 60 | + | Suggestion string // a valid alternative, empty when there is none | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | // Error implements error. | |||
| 64 | + | func (e *InvalidNameError) Error() string { | |||
| 65 | + | msg := fmt.Sprintf("invalid repository name %q: %s", e.Name, e.Reason) | |||
| 66 | + | if e.Suggestion != "" { | |||
| 67 | + | msg += fmt.Sprintf(" (try %q)", e.Suggestion) | |||
| 68 | + | } | |||
| 69 | + | return msg | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | // Unwrap makes errors.Is(err, ErrInvalidName) true. | |||
| 73 | + | func (e *InvalidNameError) Unwrap() error { return ErrInvalidName } | |||
| 74 | + | ||||
| 75 | + | // nameProblem returns why s is not a valid name, or "" when it is valid. | |||
| 76 | + | func nameProblem(s string) string { | |||
| 77 | + | if s == "" { | |||
| 78 | + | return "name is empty" | |||
| 79 | + | } | |||
| 80 | + | if len(s) > MaxRepoNameLen { | |||
| 81 | + | return fmt.Sprintf("name is longer than %d characters", MaxRepoNameLen) | |||
| 82 | + | } | |||
| 83 | + | for _, r := range s { | |||
| 84 | + | switch { | |||
| 85 | + | case r >= 'A' && r <= 'Z': | |||
| 86 | + | return "uppercase letters are not allowed" | |||
| 87 | + | case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '.', r == '_', r == '-': | |||
| 88 | + | default: | |||
| 89 | + | return fmt.Sprintf("character %q is not allowed (use a-z, 0-9, '.', '_', '-')", r) | |||
| 90 | + | } | |||
| 91 | + | } | |||
| 92 | + | switch { | |||
| 93 | + | case strings.HasPrefix(s, ".") || strings.HasSuffix(s, "."): | |||
| 94 | + | return "name must not start or end with '.'" | |||
| 95 | + | case strings.Contains(s, ".."): | |||
| 96 | + | return "name must not contain '..'" | |||
| 97 | + | case strings.HasSuffix(s, gitSuffix): | |||
| 98 | + | return "name must not end in '.git'" | |||
| 99 | + | case reservedNames[s]: | |||
| 100 | + | return "name is reserved" | |||
| 101 | + | } | |||
| 102 | + | return "" | |||
| 103 | + | } | |||
A internal/domain/name_test.go +117 -0
| @@ -0,0 +1,117 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package domain_test | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "errors" | |
| 7 | + | "strings" | |
| 8 | + | "testing" | |
| 9 | + | ||
| 10 | + | "github.com/stretchr/testify/assert" | |
| 11 | + | "github.com/stretchr/testify/require" | |
| 12 | + | ||
| 13 | + | "bnema.dev/ermite/internal/domain" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | func TestParseRepoName(t *testing.T) { | |
| 17 | + | tests := []struct { | |
| 18 | + | in string | |
| 19 | + | valid bool | |
| 20 | + | suggestion string | |
| 21 | + | }{ | |
| 22 | + | {in: "ermite", valid: true}, | |
| 23 | + | {in: "a", valid: true}, | |
| 24 | + | {in: "my-repo_1.0", valid: true}, | |
| 25 | + | {in: "x.y", valid: true}, | |
| 26 | + | {in: "0", valid: true}, | |
| 27 | + | {in: strings.Repeat("a", 100), valid: true}, | |
| 28 | + | {in: strings.Repeat("a", 101)}, | |
| 29 | + | {in: ""}, | |
| 30 | + | {in: "Ermite", suggestion: "ermite"}, | |
| 31 | + | {in: "My-Repo", suggestion: "my-repo"}, | |
| 32 | + | {in: "UPPER.git"}, // lowercase would still end in .git: no suggestion | |
| 33 | + | {in: ".hidden"}, | |
| 34 | + | {in: "trailing."}, | |
| 35 | + | {in: "a..b"}, | |
| 36 | + | {in: "tools.git"}, | |
| 37 | + | {in: "-"}, | |
| 38 | + | {in: "robots.txt"}, | |
| 39 | + | {in: "favicon.ico"}, | |
| 40 | + | {in: "-dash-ok", valid: true}, | |
| 41 | + | {in: "has space"}, | |
| 42 | + | {in: "a/b"}, | |
| 43 | + | {in: "caf\u00e9"}, | |
| 44 | + | {in: "a\x00b"}, | |
| 45 | + | } | |
| 46 | + | for _, tt := range tests { | |
| 47 | + | t.Run(tt.in, func(t *testing.T) { | |
| 48 | + | n, err := domain.ParseRepoName(tt.in) | |
| 49 | + | if tt.valid { | |
| 50 | + | require.NoError(t, err) | |
| 51 | + | assert.Equal(t, tt.in, n.String()) | |
| 52 | + | assert.False(t, n.IsZero()) | |
| 53 | + | return | |
| 54 | + | } | |
| 55 | + | require.Error(t, err) | |
| 56 | + | assert.ErrorIs(t, err, domain.ErrInvalidName) | |
| 57 | + | assert.True(t, n.IsZero()) | |
| 58 | + | var ine *domain.InvalidNameError | |
| 59 | + | require.True(t, errors.As(err, &ine)) | |
| 60 | + | assert.Equal(t, tt.suggestion, ine.Suggestion) | |
| 61 | + | if tt.suggestion != "" { | |
| 62 | + | assert.Contains(t, err.Error(), tt.suggestion) | |
| 63 | + | } | |
| 64 | + | }) | |
| 65 | + | } | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | func TestParseRepoPath(t *testing.T) { | |
| 69 | + | tests := []struct { | |
| 70 | + | in string | |
| 71 | + | want string | |
| 72 | + | valid bool | |
| 73 | + | }{ | |
| 74 | + | {"tools", "tools", true}, | |
| 75 | + | {"tools.git", "tools", true}, | |
| 76 | + | {"tools.git.git", "", false}, // only one suffix is stripped | |
| 77 | + | {".git", "", false}, | |
| 78 | + | {"Tools.git", "", false}, | |
| 79 | + | {"", "", false}, | |
| 80 | + | } | |
| 81 | + | for _, tt := range tests { | |
| 82 | + | t.Run(tt.in, func(t *testing.T) { | |
| 83 | + | n, err := domain.ParseRepoPath(tt.in) | |
| 84 | + | if !tt.valid { | |
| 85 | + | assert.ErrorIs(t, err, domain.ErrInvalidName) | |
| 86 | + | return | |
| 87 | + | } | |
| 88 | + | require.NoError(t, err) | |
| 89 | + | assert.Equal(t, tt.want, n.String()) | |
| 90 | + | }) | |
| 91 | + | } | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | func TestParseRepoNameDoesNotStripGit(t *testing.T) { | |
| 95 | + | _, err := domain.ParseRepoName("tools.git") | |
| 96 | + | assert.ErrorIs(t, err, domain.ErrInvalidName) | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | func TestParseVisibility(t *testing.T) { | |
| 100 | + | for in, want := range map[string]domain.Visibility{"public": domain.VisibilityPublic, "private": domain.VisibilityPrivate} { | |
| 101 | + | got, err := domain.ParseVisibility(in) | |
| 102 | + | require.NoError(t, err) | |
| 103 | + | assert.Equal(t, want, got) | |
| 104 | + | } | |
| 105 | + | for _, in := range []string{"", "Public", "secret"} { | |
| 106 | + | _, err := domain.ParseVisibility(in) | |
| 107 | + | assert.ErrorIs(t, err, domain.ErrInvalidVisibility, in) | |
| 108 | + | } | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | func TestPrincipal(t *testing.T) { | |
| 112 | + | var zero domain.Principal | |
| 113 | + | assert.True(t, zero.IsAnonymous()) | |
| 114 | + | assert.False(t, zero.IsRoot()) | |
| 115 | + | assert.True(t, domain.Root().IsRoot()) | |
| 116 | + | assert.Equal(t, domain.PrincipalAnonymous, domain.Anonymous().Kind()) | |
| 117 | + | } | |
| @@ -0,0 +1,117 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package domain_test | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "errors" | |||
| 7 | + | "strings" | |||
| 8 | + | "testing" | |||
| 9 | + | ||||
| 10 | + | "github.com/stretchr/testify/assert" | |||
| 11 | + | "github.com/stretchr/testify/require" | |||
| 12 | + | ||||
| 13 | + | "bnema.dev/ermite/internal/domain" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | func TestParseRepoName(t *testing.T) { | |||
| 17 | + | tests := []struct { | |||
| 18 | + | in string | |||
| 19 | + | valid bool | |||
| 20 | + | suggestion string | |||
| 21 | + | }{ | |||
| 22 | + | {in: "ermite", valid: true}, | |||
| 23 | + | {in: "a", valid: true}, | |||
| 24 | + | {in: "my-repo_1.0", valid: true}, | |||
| 25 | + | {in: "x.y", valid: true}, | |||
| 26 | + | {in: "0", valid: true}, | |||
| 27 | + | {in: strings.Repeat("a", 100), valid: true}, | |||
| 28 | + | {in: strings.Repeat("a", 101)}, | |||
| 29 | + | {in: ""}, | |||
| 30 | + | {in: "Ermite", suggestion: "ermite"}, | |||
| 31 | + | {in: "My-Repo", suggestion: "my-repo"}, | |||
| 32 | + | {in: "UPPER.git"}, // lowercase would still end in .git: no suggestion | |||
| 33 | + | {in: ".hidden"}, | |||
| 34 | + | {in: "trailing."}, | |||
| 35 | + | {in: "a..b"}, | |||
| 36 | + | {in: "tools.git"}, | |||
| 37 | + | {in: "-"}, | |||
| 38 | + | {in: "robots.txt"}, | |||
| 39 | + | {in: "favicon.ico"}, | |||
| 40 | + | {in: "-dash-ok", valid: true}, | |||
| 41 | + | {in: "has space"}, | |||
| 42 | + | {in: "a/b"}, | |||
| 43 | + | {in: "caf\u00e9"}, | |||
| 44 | + | {in: "a\x00b"}, | |||
| 45 | + | } | |||
| 46 | + | for _, tt := range tests { | |||
| 47 | + | t.Run(tt.in, func(t *testing.T) { | |||
| 48 | + | n, err := domain.ParseRepoName(tt.in) | |||
| 49 | + | if tt.valid { | |||
| 50 | + | require.NoError(t, err) | |||
| 51 | + | assert.Equal(t, tt.in, n.String()) | |||
| 52 | + | assert.False(t, n.IsZero()) | |||
| 53 | + | return | |||
| 54 | + | } | |||
| 55 | + | require.Error(t, err) | |||
| 56 | + | assert.ErrorIs(t, err, domain.ErrInvalidName) | |||
| 57 | + | assert.True(t, n.IsZero()) | |||
| 58 | + | var ine *domain.InvalidNameError | |||
| 59 | + | require.True(t, errors.As(err, &ine)) | |||
| 60 | + | assert.Equal(t, tt.suggestion, ine.Suggestion) | |||
| 61 | + | if tt.suggestion != "" { | |||
| 62 | + | assert.Contains(t, err.Error(), tt.suggestion) | |||
| 63 | + | } | |||
| 64 | + | }) | |||
| 65 | + | } | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | func TestParseRepoPath(t *testing.T) { | |||
| 69 | + | tests := []struct { | |||
| 70 | + | in string | |||
| 71 | + | want string | |||
| 72 | + | valid bool | |||
| 73 | + | }{ | |||
| 74 | + | {"tools", "tools", true}, | |||
| 75 | + | {"tools.git", "tools", true}, | |||
| 76 | + | {"tools.git.git", "", false}, // only one suffix is stripped | |||
| 77 | + | {".git", "", false}, | |||
| 78 | + | {"Tools.git", "", false}, | |||
| 79 | + | {"", "", false}, | |||
| 80 | + | } | |||
| 81 | + | for _, tt := range tests { | |||
| 82 | + | t.Run(tt.in, func(t *testing.T) { | |||
| 83 | + | n, err := domain.ParseRepoPath(tt.in) | |||
| 84 | + | if !tt.valid { | |||
| 85 | + | assert.ErrorIs(t, err, domain.ErrInvalidName) | |||
| 86 | + | return | |||
| 87 | + | } | |||
| 88 | + | require.NoError(t, err) | |||
| 89 | + | assert.Equal(t, tt.want, n.String()) | |||
| 90 | + | }) | |||
| 91 | + | } | |||
| 92 | + | } | |||
| 93 | + | ||||
| 94 | + | func TestParseRepoNameDoesNotStripGit(t *testing.T) { | |||
| 95 | + | _, err := domain.ParseRepoName("tools.git") | |||
| 96 | + | assert.ErrorIs(t, err, domain.ErrInvalidName) | |||
| 97 | + | } | |||
| 98 | + | ||||
| 99 | + | func TestParseVisibility(t *testing.T) { | |||
| 100 | + | for in, want := range map[string]domain.Visibility{"public": domain.VisibilityPublic, "private": domain.VisibilityPrivate} { | |||
| 101 | + | got, err := domain.ParseVisibility(in) | |||
| 102 | + | require.NoError(t, err) | |||
| 103 | + | assert.Equal(t, want, got) | |||
| 104 | + | } | |||
| 105 | + | for _, in := range []string{"", "Public", "secret"} { | |||
| 106 | + | _, err := domain.ParseVisibility(in) | |||
| 107 | + | assert.ErrorIs(t, err, domain.ErrInvalidVisibility, in) | |||
| 108 | + | } | |||
| 109 | + | } | |||
| 110 | + | ||||
| 111 | + | func TestPrincipal(t *testing.T) { | |||
| 112 | + | var zero domain.Principal | |||
| 113 | + | assert.True(t, zero.IsAnonymous()) | |||
| 114 | + | assert.False(t, zero.IsRoot()) | |||
| 115 | + | assert.True(t, domain.Root().IsRoot()) | |||
| 116 | + | assert.Equal(t, domain.PrincipalAnonymous, domain.Anonymous().Kind()) | |||
| 117 | + | } | |||
A internal/domain/principal.go +33 -0
| @@ -0,0 +1,33 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package domain | |
| 4 | + | ||
| 5 | + | // PrincipalKind tells who a caller is. | |
| 6 | + | type PrincipalKind uint8 | |
| 7 | + | ||
| 8 | + | // Principal kinds. The zero value is anonymous, the least privileged. | |
| 9 | + | const ( | |
| 10 | + | // PrincipalAnonymous is a caller without credentials. | |
| 11 | + | PrincipalAnonymous PrincipalKind = iota | |
| 12 | + | // PrincipalRoot is the holder of the admin token. | |
| 13 | + | PrincipalRoot | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | // Principal is the authenticated identity of a caller. Stored tokens add | |
| 17 | + | // scoped principals later. | |
| 18 | + | type Principal struct{ kind PrincipalKind } | |
| 19 | + | ||
| 20 | + | // Anonymous returns the principal of a caller without credentials. | |
| 21 | + | func Anonymous() Principal { return Principal{kind: PrincipalAnonymous} } | |
| 22 | + | ||
| 23 | + | // Root returns the principal of the admin token holder. | |
| 24 | + | func Root() Principal { return Principal{kind: PrincipalRoot} } | |
| 25 | + | ||
| 26 | + | // Kind returns the kind of principal. | |
| 27 | + | func (p Principal) Kind() PrincipalKind { return p.kind } | |
| 28 | + | ||
| 29 | + | // IsRoot reports whether p may do everything. | |
| 30 | + | func (p Principal) IsRoot() bool { return p.kind == PrincipalRoot } | |
| 31 | + | ||
| 32 | + | // IsAnonymous reports whether p has no credentials. | |
| 33 | + | func (p Principal) IsAnonymous() bool { return p.kind == PrincipalAnonymous } | |
| @@ -0,0 +1,33 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package domain | |||
| 4 | + | ||||
| 5 | + | // PrincipalKind tells who a caller is. | |||
| 6 | + | type PrincipalKind uint8 | |||
| 7 | + | ||||
| 8 | + | // Principal kinds. The zero value is anonymous, the least privileged. | |||
| 9 | + | const ( | |||
| 10 | + | // PrincipalAnonymous is a caller without credentials. | |||
| 11 | + | PrincipalAnonymous PrincipalKind = iota | |||
| 12 | + | // PrincipalRoot is the holder of the admin token. | |||
| 13 | + | PrincipalRoot | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | // Principal is the authenticated identity of a caller. Stored tokens add | |||
| 17 | + | // scoped principals later. | |||
| 18 | + | type Principal struct{ kind PrincipalKind } | |||
| 19 | + | ||||
| 20 | + | // Anonymous returns the principal of a caller without credentials. | |||
| 21 | + | func Anonymous() Principal { return Principal{kind: PrincipalAnonymous} } | |||
| 22 | + | ||||
| 23 | + | // Root returns the principal of the admin token holder. | |||
| 24 | + | func Root() Principal { return Principal{kind: PrincipalRoot} } | |||
| 25 | + | ||||
| 26 | + | // Kind returns the kind of principal. | |||
| 27 | + | func (p Principal) Kind() PrincipalKind { return p.kind } | |||
| 28 | + | ||||
| 29 | + | // IsRoot reports whether p may do everything. | |||
| 30 | + | func (p Principal) IsRoot() bool { return p.kind == PrincipalRoot } | |||
| 31 | + | ||||
| 32 | + | // IsAnonymous reports whether p has no credentials. | |||
| 33 | + | func (p Principal) IsAnonymous() bool { return p.kind == PrincipalAnonymous } | |||
A internal/domain/repo.go +41 -0
| @@ -0,0 +1,41 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package domain | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "fmt" | |
| 7 | + | "time" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // Visibility is the access level of a repository. | |
| 11 | + | type Visibility string | |
| 12 | + | ||
| 13 | + | // Visibility values. | |
| 14 | + | const ( | |
| 15 | + | // VisibilityPrivate hides the repository from anonymous callers. | |
| 16 | + | VisibilityPrivate Visibility = "private" | |
| 17 | + | // VisibilityPublic lets anonymous callers read the repository. | |
| 18 | + | VisibilityPublic Visibility = "public" | |
| 19 | + | ) | |
| 20 | + | ||
| 21 | + | // ParseVisibility parses "public" or "private". | |
| 22 | + | func ParseVisibility(s string) (Visibility, error) { | |
| 23 | + | switch v := Visibility(s); v { | |
| 24 | + | case VisibilityPublic, VisibilityPrivate: | |
| 25 | + | return v, nil | |
| 26 | + | } | |
| 27 | + | return "", fmt.Errorf("%w: %q (want public or private)", ErrInvalidVisibility, s) | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | // String returns the visibility as text. | |
| 31 | + | func (v Visibility) String() string { return string(v) } | |
| 32 | + | ||
| 33 | + | // Repo is the metadata ermite owns about a repository. Git-owned state (refs, | |
| 34 | + | // HEAD) stays in Git. | |
| 35 | + | type Repo struct { | |
| 36 | + | Name RepoName | |
| 37 | + | Description string | |
| 38 | + | Visibility Visibility | |
| 39 | + | CreatedAt time.Time | |
| 40 | + | PushedAt time.Time // zero until the first push | |
| 41 | + | } | |
| @@ -0,0 +1,41 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package domain | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "fmt" | |||
| 7 | + | "time" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | // Visibility is the access level of a repository. | |||
| 11 | + | type Visibility string | |||
| 12 | + | ||||
| 13 | + | // Visibility values. | |||
| 14 | + | const ( | |||
| 15 | + | // VisibilityPrivate hides the repository from anonymous callers. | |||
| 16 | + | VisibilityPrivate Visibility = "private" | |||
| 17 | + | // VisibilityPublic lets anonymous callers read the repository. | |||
| 18 | + | VisibilityPublic Visibility = "public" | |||
| 19 | + | ) | |||
| 20 | + | ||||
| 21 | + | // ParseVisibility parses "public" or "private". | |||
| 22 | + | func ParseVisibility(s string) (Visibility, error) { | |||
| 23 | + | switch v := Visibility(s); v { | |||
| 24 | + | case VisibilityPublic, VisibilityPrivate: | |||
| 25 | + | return v, nil | |||
| 26 | + | } | |||
| 27 | + | return "", fmt.Errorf("%w: %q (want public or private)", ErrInvalidVisibility, s) | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | // String returns the visibility as text. | |||
| 31 | + | func (v Visibility) String() string { return string(v) } | |||
| 32 | + | ||||
| 33 | + | // Repo is the metadata ermite owns about a repository. Git-owned state (refs, | |||
| 34 | + | // HEAD) stays in Git. | |||
| 35 | + | type Repo struct { | |||
| 36 | + | Name RepoName | |||
| 37 | + | Description string | |||
| 38 | + | Visibility Visibility | |||
| 39 | + | CreatedAt time.Time | |||
| 40 | + | PushedAt time.Time // zero until the first push | |||
| 41 | + | } | |||