feat(rpc): serve ListPasskeys and RemovePasskey and list the passkeys feature
- commit
- 0818b8d20e88f0631264c3a462f10a75350de29d
- author
- brice · · 2026-10-12 14:59 UTC
- parent
- a2a22bf
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
11 files · +236 -23
M internal/adapters/client/client_test.go +5 -2
| @@ -140,9 +140,12 @@ func TestMissingFeatures(t *testing.T) { | |||
| 140 | 140 | assert.Empty(t, client.MissingFeatures(client.UsedFeatures())) | |
| 141 | 141 | assert.Equal(t, | |
| 142 | 142 | []string{client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureSigningKey}, | |
| 143 | - | client.MissingFeatures([]string{client.FeatureRepoUpdate, client.FeatureSession, "other"})) | |
| 143 | + | client.MissingFeatures([]string{client.FeatureRepoUpdate, client.FeatureSession, client.FeaturePasskeys, "other"})) | |
| 144 | 144 | assert.Equal(t, []string{client.FeatureSigningKey}, client.MissingFeatures([]string{ | |
| 145 | - | client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureSession, | |
| 145 | + | client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureSession, client.FeaturePasskeys, | |
| 146 | + | })) | |
| 147 | + | assert.Equal(t, []string{client.FeaturePasskeys}, client.MissingFeatures([]string{ | |
| 148 | + | client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureSigningKey, client.FeatureSession, | |
| 146 | 149 | })) | |
| 147 | 150 | assert.Equal(t, client.UsedFeatures(), client.MissingFeatures(nil)) | |
| 148 | 151 | } | |
| @@ -140,9 +140,12 @@ func TestMissingFeatures(t *testing.T) { | |||||
| 140 | assert.Empty(t, client.MissingFeatures(client.UsedFeatures())) | 140 | assert.Empty(t, client.MissingFeatures(client.UsedFeatures())) | ||
| 141 | assert.Equal(t, | 141 | assert.Equal(t, | ||
| 142 | []string{client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureSigningKey}, | 142 | []string{client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureSigningKey}, | ||
| 143 | - | client.MissingFeatures([]string{client.FeatureRepoUpdate, client.FeatureSession, "other"})) | 143 | + | client.MissingFeatures([]string{client.FeatureRepoUpdate, client.FeatureSession, client.FeaturePasskeys, "other"})) |
| 144 | assert.Equal(t, []string{client.FeatureSigningKey}, client.MissingFeatures([]string{ | 144 | assert.Equal(t, []string{client.FeatureSigningKey}, client.MissingFeatures([]string{ | ||
| 145 | - | client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureSession, | 145 | + | client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureSession, client.FeaturePasskeys, |
| 146 | + | })) | |||
| 147 | + | assert.Equal(t, []string{client.FeaturePasskeys}, client.MissingFeatures([]string{ | |||
| 148 | + | client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureSigningKey, client.FeatureSession, | |||
| 146 | })) | 149 | })) | ||
| 147 | assert.Equal(t, client.UsedFeatures(), client.MissingFeatures(nil)) | 150 | assert.Equal(t, client.UsedFeatures(), client.MissingFeatures(nil)) | ||
| 148 | } | 151 | } | ||
M internal/adapters/client/features.go +5 -1
| @@ -22,11 +22,15 @@ const ( | |||
| 22 | 22 | FeatureSigningKey = "signing_key" | |
| 23 | 23 | // FeatureSession is SessionService (`ermite web login`, `web logout`). | |
| 24 | 24 | FeatureSession = "session" | |
| 25 | + | // FeaturePasskeys is SessionService.ListPasskeys and RemovePasskey | |
| 26 | + | // (`ermite web passkey`). A server lists it only when passkeys are | |
| 27 | + | // available (https public URL with a DNS host name). | |
| 28 | + | FeaturePasskeys = "passkeys" | |
| 25 | 29 | ) | |
| 26 | 30 | ||
| 27 | 31 | // UsedFeatures lists the features the CLI uses, in the order they are reported. | |
| 28 | 32 | func UsedFeatures() []string { | |
| 29 | - | return []string{FeatureRepoUpdate, FeatureEventsResume, FeatureMergeRequests, FeatureMergeDeleteBranch, FeatureSigningKey, FeatureSession} | |
| 33 | + | return []string{FeatureRepoUpdate, FeatureEventsResume, FeatureMergeRequests, FeatureMergeDeleteBranch, FeatureSigningKey, FeatureSession, FeaturePasskeys} | |
| 30 | 34 | } | |
| 31 | 35 | ||
| 32 | 36 | // MissingFeatures returns the features the CLI uses that have is lacking. | |
| @@ -22,11 +22,15 @@ const ( | |||||
| 22 | FeatureSigningKey = "signing_key" | 22 | FeatureSigningKey = "signing_key" | ||
| 23 | // FeatureSession is SessionService (`ermite web login`, `web logout`). | 23 | // FeatureSession is SessionService (`ermite web login`, `web logout`). | ||
| 24 | FeatureSession = "session" | 24 | FeatureSession = "session" | ||
| 25 | + | // FeaturePasskeys is SessionService.ListPasskeys and RemovePasskey | |||
| 26 | + | // (`ermite web passkey`). A server lists it only when passkeys are | |||
| 27 | + | // available (https public URL with a DNS host name). | |||
| 28 | + | FeaturePasskeys = "passkeys" | |||
| 25 | ) | 29 | ) | ||
| 26 | 30 | ||||
| 27 | // UsedFeatures lists the features the CLI uses, in the order they are reported. | 31 | // UsedFeatures lists the features the CLI uses, in the order they are reported. | ||
| 28 | func UsedFeatures() []string { | 32 | func UsedFeatures() []string { | ||
| 29 | - | return []string{FeatureRepoUpdate, FeatureEventsResume, FeatureMergeRequests, FeatureMergeDeleteBranch, FeatureSigningKey, FeatureSession} | 33 | + | return []string{FeatureRepoUpdate, FeatureEventsResume, FeatureMergeRequests, FeatureMergeDeleteBranch, FeatureSigningKey, FeatureSession, FeaturePasskeys} |
| 30 | } | 34 | } | ||
| 31 | 35 | ||||
| 32 | // MissingFeatures returns the features the CLI uses that have is lacking. | 36 | // MissingFeatures returns the features the CLI uses that have is lacking. | ||
M internal/adapters/rpc/api.go +16 -3
| @@ -10,6 +10,7 @@ import ( | |||
| 10 | 10 | "context" | |
| 11 | 11 | "net/http" | |
| 12 | 12 | "net/netip" | |
| 13 | + | "slices" | |
| 13 | 14 | ||
| 14 | 15 | "connectrpc.com/connect" | |
| 15 | 16 | "github.com/bnema/zerowrap" | |
| @@ -25,9 +26,13 @@ import ( | |||
| 25 | 26 | const MaxRequestBytes = 1 << 20 | |
| 26 | 27 | ||
| 27 | 28 | // Features are the optional capabilities of the contract that this server | |
| 28 | - | // implements, as GetVersion lists them. | |
| 29 | + | // always implements, as GetVersion lists them. FeaturePasskeys comes on top | |
| 30 | + | // when Config.Passkeys is set. | |
| 29 | 31 | var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths"} | |
| 30 | 32 | ||
| 33 | + | // FeaturePasskeys is listed by GetVersion when passkeys are available. | |
| 34 | + | const FeaturePasskeys = "passkeys" | |
| 35 | + | ||
| 31 | 36 | // Config configures the API. | |
| 32 | 37 | type Config struct { | |
| 33 | 38 | Auth ports.AuthService | |
| @@ -50,6 +55,10 @@ type Config struct { | |||
| 50 | 55 | // Leave it unset rather than assigning a typed nil pointer (a nil *T in | |
| 51 | 56 | // the interface is not nil, and the service would be served and called). | |
| 52 | 57 | Sessions ports.SessionService | |
| 58 | + | // Passkeys serves ListPasskeys and RemovePasskey; nil means passkeys are | |
| 59 | + | // not available (the RPCs answer failed_precondition and GetVersion does | |
| 60 | + | // not list "passkeys"). Same rule as Sessions about typed nil pointers. | |
| 61 | + | Passkeys ports.PasskeyService | |
| 53 | 62 | } | |
| 54 | 63 | ||
| 55 | 64 | // API is the set of Connect handlers of the contract. | |
| @@ -76,10 +85,14 @@ func New(cfg Config) *API { | |||
| 76 | 85 | mux.Handle(ermiteconnect.NewGitServiceHandler(&gitService{cfg.Browse}, opts...)) | |
| 77 | 86 | mux.Handle(ermiteconnect.NewTokenServiceHandler(&tokenService{cfg.Tokens}, opts...)) | |
| 78 | 87 | mux.Handle(ermiteconnect.NewEventServiceHandler(&eventService{cfg.Events}, opts...)) | |
| 79 | - | mux.Handle(ermiteconnect.NewServerServiceHandler(&serverService{version: cfg.Version, key: cfg.SigningKey}, opts...)) | |
| 88 | + | features := slices.Clone(Features) | |
| 89 | + | if cfg.Passkeys != nil { | |
| 90 | + | features = append(features, FeaturePasskeys) | |
| 91 | + | } | |
| 92 | + | mux.Handle(ermiteconnect.NewServerServiceHandler(&serverService{version: cfg.Version, features: features, key: cfg.SigningKey}, opts...)) | |
| 80 | 93 | mux.Handle(ermiteconnect.NewMergeRequestServiceHandler(&mergeRequestService{cfg.MergeRequests}, opts...)) | |
| 81 | 94 | if cfg.Sessions != nil { | |
| 82 | - | mux.Handle(ermiteconnect.NewSessionServiceHandler(&sessionService{cfg.Sessions}, opts...)) | |
| 95 | + | mux.Handle(ermiteconnect.NewSessionServiceHandler(&sessionService{sessions: cfg.Sessions, passkeys: cfg.Passkeys}, opts...)) | |
| 83 | 96 | } | |
| 84 | 97 | return &API{handler: mux} | |
| 85 | 98 | } | |
| @@ -10,6 +10,7 @@ import ( | |||||
| 10 | "context" | 10 | "context" | ||
| 11 | "net/http" | 11 | "net/http" | ||
| 12 | "net/netip" | 12 | "net/netip" | ||
| 13 | + | "slices" | |||
| 13 | 14 | ||||
| 14 | "connectrpc.com/connect" | 15 | "connectrpc.com/connect" | ||
| 15 | "github.com/bnema/zerowrap" | 16 | "github.com/bnema/zerowrap" | ||
| @@ -25,9 +26,13 @@ import ( | |||||
| 25 | const MaxRequestBytes = 1 << 20 | 26 | const MaxRequestBytes = 1 << 20 | ||
| 26 | 27 | ||||
| 27 | // Features are the optional capabilities of the contract that this server | 28 | // Features are the optional capabilities of the contract that this server | ||
| 28 | - | // implements, as GetVersion lists them. | 29 | + | // always implements, as GetVersion lists them. FeaturePasskeys comes on top |
| 30 | + | // when Config.Passkeys is set. | |||
| 29 | var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths"} | 31 | var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths"} | ||
| 30 | 32 | ||||
| 33 | + | // FeaturePasskeys is listed by GetVersion when passkeys are available. | |||
| 34 | + | const FeaturePasskeys = "passkeys" | |||
| 35 | + | ||||
| 31 | // Config configures the API. | 36 | // Config configures the API. | ||
| 32 | type Config struct { | 37 | type Config struct { | ||
| 33 | Auth ports.AuthService | 38 | Auth ports.AuthService | ||
| @@ -50,6 +55,10 @@ type Config struct { | |||||
| 50 | // Leave it unset rather than assigning a typed nil pointer (a nil *T in | 55 | // Leave it unset rather than assigning a typed nil pointer (a nil *T in | ||
| 51 | // the interface is not nil, and the service would be served and called). | 56 | // the interface is not nil, and the service would be served and called). | ||
| 52 | Sessions ports.SessionService | 57 | Sessions ports.SessionService | ||
| 58 | + | // Passkeys serves ListPasskeys and RemovePasskey; nil means passkeys are | |||
| 59 | + | // not available (the RPCs answer failed_precondition and GetVersion does | |||
| 60 | + | // not list "passkeys"). Same rule as Sessions about typed nil pointers. | |||
| 61 | + | Passkeys ports.PasskeyService | |||
| 53 | } | 62 | } | ||
| 54 | 63 | ||||
| 55 | // API is the set of Connect handlers of the contract. | 64 | // API is the set of Connect handlers of the contract. | ||
| @@ -76,10 +85,14 @@ func New(cfg Config) *API { | |||||
| 76 | mux.Handle(ermiteconnect.NewGitServiceHandler(&gitService{cfg.Browse}, opts...)) | 85 | mux.Handle(ermiteconnect.NewGitServiceHandler(&gitService{cfg.Browse}, opts...)) | ||
| 77 | mux.Handle(ermiteconnect.NewTokenServiceHandler(&tokenService{cfg.Tokens}, opts...)) | 86 | mux.Handle(ermiteconnect.NewTokenServiceHandler(&tokenService{cfg.Tokens}, opts...)) | ||
| 78 | mux.Handle(ermiteconnect.NewEventServiceHandler(&eventService{cfg.Events}, opts...)) | 87 | mux.Handle(ermiteconnect.NewEventServiceHandler(&eventService{cfg.Events}, opts...)) | ||
| 79 | - | mux.Handle(ermiteconnect.NewServerServiceHandler(&serverService{version: cfg.Version, key: cfg.SigningKey}, opts...)) | 88 | + | features := slices.Clone(Features) |
| 89 | + | if cfg.Passkeys != nil { | |||
| 90 | + | features = append(features, FeaturePasskeys) | |||
| 91 | + | } | |||
| 92 | + | mux.Handle(ermiteconnect.NewServerServiceHandler(&serverService{version: cfg.Version, features: features, key: cfg.SigningKey}, opts...)) | |||
| 80 | mux.Handle(ermiteconnect.NewMergeRequestServiceHandler(&mergeRequestService{cfg.MergeRequests}, opts...)) | 93 | mux.Handle(ermiteconnect.NewMergeRequestServiceHandler(&mergeRequestService{cfg.MergeRequests}, opts...)) | ||
| 81 | if cfg.Sessions != nil { | 94 | if cfg.Sessions != nil { | ||
| 82 | - | mux.Handle(ermiteconnect.NewSessionServiceHandler(&sessionService{cfg.Sessions}, opts...)) | 95 | + | mux.Handle(ermiteconnect.NewSessionServiceHandler(&sessionService{sessions: cfg.Sessions, passkeys: cfg.Passkeys}, opts...)) |
| 83 | } | 96 | } | ||
| 84 | return &API{handler: mux} | 97 | return &API{handler: mux} | ||
| 85 | } | 98 | } | ||
M internal/adapters/rpc/convert.go +17 -0
| @@ -3,6 +3,7 @@ | |||
| 3 | 3 | package rpc | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | + | "encoding/base64" | |
| 6 | 7 | "fmt" | |
| 7 | 8 | "time" | |
| 8 | 9 | ||
| @@ -403,3 +404,19 @@ func repoOrName(we ports.WatchEvent, name domain.RepoName, vis domain.Visibility | |||
| 403 | 404 | } | |
| 404 | 405 | return &pb.Repo{Name: name.String(), Visibility: visibilityToPB(vis)} | |
| 405 | 406 | } | |
| 407 | + | ||
| 408 | + | // passkeysToPB converts passkeys; the credential id is base64url without | |
| 409 | + | // padding, the form RemovePasskey takes back. Key material never leaves. | |
| 410 | + | func passkeysToPB(list []domain.Passkey) []*pb.Passkey { | |
| 411 | + | out := make([]*pb.Passkey, 0, len(list)) | |
| 412 | + | for _, p := range list { | |
| 413 | + | out = append(out, &pb.Passkey{ | |
| 414 | + | Id: base64.RawURLEncoding.EncodeToString(p.ID), | |
| 415 | + | Name: p.Name, | |
| 416 | + | CreatedAt: timestamp(p.CreatedAt), | |
| 417 | + | LastUsedAt: timestamp(p.LastUsedAt), | |
| 418 | + | Synced: p.BackupState, | |
| 419 | + | }) | |
| 420 | + | } | |
| 421 | + | return out | |
| 422 | + | } | |
| @@ -3,6 +3,7 @@ | |||||
| 3 | package rpc | 3 | package rpc | ||
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | + | "encoding/base64" | |||
| 6 | "fmt" | 7 | "fmt" | ||
| 7 | "time" | 8 | "time" | ||
| 8 | 9 | ||||
| @@ -403,3 +404,19 @@ func repoOrName(we ports.WatchEvent, name domain.RepoName, vis domain.Visibility | |||||
| 403 | } | 404 | } | ||
| 404 | return &pb.Repo{Name: name.String(), Visibility: visibilityToPB(vis)} | 405 | return &pb.Repo{Name: name.String(), Visibility: visibilityToPB(vis)} | ||
| 405 | } | 406 | } | ||
| 407 | + | ||||
| 408 | + | // passkeysToPB converts passkeys; the credential id is base64url without | |||
| 409 | + | // padding, the form RemovePasskey takes back. Key material never leaves. | |||
| 410 | + | func passkeysToPB(list []domain.Passkey) []*pb.Passkey { | |||
| 411 | + | out := make([]*pb.Passkey, 0, len(list)) | |||
| 412 | + | for _, p := range list { | |||
| 413 | + | out = append(out, &pb.Passkey{ | |||
| 414 | + | Id: base64.RawURLEncoding.EncodeToString(p.ID), | |||
| 415 | + | Name: p.Name, | |||
| 416 | + | CreatedAt: timestamp(p.CreatedAt), | |||
| 417 | + | LastUsedAt: timestamp(p.LastUsedAt), | |||
| 418 | + | Synced: p.BackupState, | |||
| 419 | + | }) | |||
| 420 | + | } | |||
| 421 | + | return out | |||
| 422 | + | } | |||
M internal/adapters/rpc/server.go +4 -3
| @@ -14,12 +14,13 @@ import ( | |||
| 14 | 14 | ||
| 15 | 15 | // serverService implements ermiteconnect.ServerServiceHandler. | |
| 16 | 16 | type serverService struct { | |
| 17 | - | version string | |
| 18 | - | key ports.SigningKeyService | |
| 17 | + | version string | |
| 18 | + | features []string | |
| 19 | + | key ports.SigningKeyService | |
| 19 | 20 | } | |
| 20 | 21 | ||
| 21 | 22 | func (s *serverService) GetVersion(context.Context, *connect.Request[pb.GetVersionRequest]) (*connect.Response[pb.GetVersionResponse], error) { | |
| 22 | - | return connect.NewResponse(&pb.GetVersionResponse{Version: s.version, Features: slices.Clone(Features)}), nil | |
| 23 | + | return connect.NewResponse(&pb.GetVersionResponse{Version: s.version, Features: slices.Clone(s.features)}), nil | |
| 23 | 24 | } | |
| 24 | 25 | ||
| 25 | 26 | // GetSigningKey returns the public key of the commits ermite signs. It needs | |
| @@ -14,12 +14,13 @@ import ( | |||||
| 14 | 14 | ||||
| 15 | // serverService implements ermiteconnect.ServerServiceHandler. | 15 | // serverService implements ermiteconnect.ServerServiceHandler. | ||
| 16 | type serverService struct { | 16 | type serverService struct { | ||
| 17 | - | version string | 17 | + | version string |
| 18 | - | key ports.SigningKeyService | 18 | + | features []string |
| 19 | + | key ports.SigningKeyService | |||
| 19 | } | 20 | } | ||
| 20 | 21 | ||||
| 21 | func (s *serverService) GetVersion(context.Context, *connect.Request[pb.GetVersionRequest]) (*connect.Response[pb.GetVersionResponse], error) { | 22 | func (s *serverService) GetVersion(context.Context, *connect.Request[pb.GetVersionRequest]) (*connect.Response[pb.GetVersionResponse], error) { | ||
| 22 | - | return connect.NewResponse(&pb.GetVersionResponse{Version: s.version, Features: slices.Clone(Features)}), nil | 23 | + | return connect.NewResponse(&pb.GetVersionResponse{Version: s.version, Features: slices.Clone(s.features)}), nil |
| 23 | } | 24 | } | ||
| 24 | 25 | ||||
| 25 | // GetSigningKey returns the public key of the commits ermite signs. It needs | 26 | // GetSigningKey returns the public key of the commits ermite signs. It needs | ||
M internal/adapters/rpc/server_test.go +25 -1
| @@ -4,20 +4,24 @@ package rpc | |||
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "context" | |
| 7 | + | "slices" | |
| 7 | 8 | "testing" | |
| 8 | 9 | ||
| 10 | + | "net/http/httptest" | |
| 11 | + | ||
| 9 | 12 | "connectrpc.com/connect" | |
| 10 | 13 | "github.com/stretchr/testify/assert" | |
| 11 | 14 | "github.com/stretchr/testify/require" | |
| 12 | 15 | ||
| 13 | 16 | pb "bnema.dev/ermite-api/gen/ermite" | |
| 17 | + | "bnema.dev/ermite-api/gen/ermite/ermiteconnect" | |
| 14 | 18 | "bnema.dev/ermite/internal/domain" | |
| 15 | 19 | "bnema.dev/ermite/internal/ports/mocks" | |
| 16 | 20 | ) | |
| 17 | 21 | ||
| 18 | 22 | func TestServerServiceGetVersion(t *testing.T) { | |
| 19 | 23 | t.Parallel() | |
| 20 | - | svc := &serverService{version: "v1.2.3", key: mocks.NewSigningKeyService(t)} | |
| 24 | + | svc := &serverService{version: "v1.2.3", features: slices.Clone(Features), key: mocks.NewSigningKeyService(t)} | |
| 21 | 25 | resp, err := svc.GetVersion(context.Background(), connect.NewRequest(&pb.GetVersionRequest{})) | |
| 22 | 26 | require.NoError(t, err) | |
| 23 | 27 | assert.Equal(t, "v1.2.3", resp.Msg.GetVersion()) | |
| @@ -43,3 +47,23 @@ func TestServerServiceGetSigningKey(t *testing.T) { | |||
| 43 | 47 | assert.Equal(t, "ermite@example.com", resp.Msg.GetCommitterEmail()) | |
| 44 | 48 | assert.Equal(t, "ermite@example.com ssh-ed25519 AAAA", resp.Msg.GetAllowedSigners()) | |
| 45 | 49 | } | |
| 50 | + | ||
| 51 | + | // GetVersion lists "passkeys" only when the API is built with passkeys. | |
| 52 | + | func TestAPIListsPasskeysFeatureWhenAvailable(t *testing.T) { | |
| 53 | + | t.Parallel() | |
| 54 | + | list := func(cfg Config) []string { | |
| 55 | + | cfg.Sessions = mocks.NewSessionService(t) | |
| 56 | + | cfg.SigningKey = mocks.NewSigningKeyService(t) | |
| 57 | + | srv := httptest.NewServer(New(cfg).Handler()) | |
| 58 | + | t.Cleanup(srv.Close) | |
| 59 | + | c := ermiteconnect.NewServerServiceClient(srv.Client(), srv.URL) | |
| 60 | + | resp, err := c.GetVersion(context.Background(), connect.NewRequest(&pb.GetVersionRequest{})) | |
| 61 | + | require.NoError(t, err) | |
| 62 | + | return resp.Msg.GetFeatures() | |
| 63 | + | } | |
| 64 | + | assert.Equal(t, Features, list(Config{})) | |
| 65 | + | assert.NotContains(t, list(Config{}), "passkeys") | |
| 66 | + | got := list(Config{Passkeys: mocks.NewPasskeyService(t)}) | |
| 67 | + | assert.Equal(t, append(slices.Clone(Features), "passkeys"), got) | |
| 68 | + | assert.NotContains(t, Features, "passkeys", "the shared list is not modified") | |
| 69 | + | } | |
| @@ -4,20 +4,24 @@ package rpc | |||||
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | "context" | 6 | "context" | ||
| 7 | + | "slices" | |||
| 7 | "testing" | 8 | "testing" | ||
| 8 | 9 | ||||
| 10 | + | "net/http/httptest" | |||
| 11 | + | ||||
| 9 | "connectrpc.com/connect" | 12 | "connectrpc.com/connect" | ||
| 10 | "github.com/stretchr/testify/assert" | 13 | "github.com/stretchr/testify/assert" | ||
| 11 | "github.com/stretchr/testify/require" | 14 | "github.com/stretchr/testify/require" | ||
| 12 | 15 | ||||
| 13 | pb "bnema.dev/ermite-api/gen/ermite" | 16 | pb "bnema.dev/ermite-api/gen/ermite" | ||
| 17 | + | "bnema.dev/ermite-api/gen/ermite/ermiteconnect" | |||
| 14 | "bnema.dev/ermite/internal/domain" | 18 | "bnema.dev/ermite/internal/domain" | ||
| 15 | "bnema.dev/ermite/internal/ports/mocks" | 19 | "bnema.dev/ermite/internal/ports/mocks" | ||
| 16 | ) | 20 | ) | ||
| 17 | 21 | ||||
| 18 | func TestServerServiceGetVersion(t *testing.T) { | 22 | func TestServerServiceGetVersion(t *testing.T) { | ||
| 19 | t.Parallel() | 23 | t.Parallel() | ||
| 20 | - | svc := &serverService{version: "v1.2.3", key: mocks.NewSigningKeyService(t)} | 24 | + | svc := &serverService{version: "v1.2.3", features: slices.Clone(Features), key: mocks.NewSigningKeyService(t)} |
| 21 | resp, err := svc.GetVersion(context.Background(), connect.NewRequest(&pb.GetVersionRequest{})) | 25 | resp, err := svc.GetVersion(context.Background(), connect.NewRequest(&pb.GetVersionRequest{})) | ||
| 22 | require.NoError(t, err) | 26 | require.NoError(t, err) | ||
| 23 | assert.Equal(t, "v1.2.3", resp.Msg.GetVersion()) | 27 | assert.Equal(t, "v1.2.3", resp.Msg.GetVersion()) | ||
| @@ -43,3 +47,23 @@ func TestServerServiceGetSigningKey(t *testing.T) { | |||||
| 43 | assert.Equal(t, "ermite@example.com", resp.Msg.GetCommitterEmail()) | 47 | assert.Equal(t, "ermite@example.com", resp.Msg.GetCommitterEmail()) | ||
| 44 | assert.Equal(t, "ermite@example.com ssh-ed25519 AAAA", resp.Msg.GetAllowedSigners()) | 48 | assert.Equal(t, "ermite@example.com ssh-ed25519 AAAA", resp.Msg.GetAllowedSigners()) | ||
| 45 | } | 49 | } | ||
| 50 | + | ||||
| 51 | + | // GetVersion lists "passkeys" only when the API is built with passkeys. | |||
| 52 | + | func TestAPIListsPasskeysFeatureWhenAvailable(t *testing.T) { | |||
| 53 | + | t.Parallel() | |||
| 54 | + | list := func(cfg Config) []string { | |||
| 55 | + | cfg.Sessions = mocks.NewSessionService(t) | |||
| 56 | + | cfg.SigningKey = mocks.NewSigningKeyService(t) | |||
| 57 | + | srv := httptest.NewServer(New(cfg).Handler()) | |||
| 58 | + | t.Cleanup(srv.Close) | |||
| 59 | + | c := ermiteconnect.NewServerServiceClient(srv.Client(), srv.URL) | |||
| 60 | + | resp, err := c.GetVersion(context.Background(), connect.NewRequest(&pb.GetVersionRequest{})) | |||
| 61 | + | require.NoError(t, err) | |||
| 62 | + | return resp.Msg.GetFeatures() | |||
| 63 | + | } | |||
| 64 | + | assert.Equal(t, Features, list(Config{})) | |||
| 65 | + | assert.NotContains(t, list(Config{}), "passkeys") | |||
| 66 | + | got := list(Config{Passkeys: mocks.NewPasskeyService(t)}) | |||
| 67 | + | assert.Equal(t, append(slices.Clone(Features), "passkeys"), got) | |||
| 68 | + | assert.NotContains(t, Features, "passkeys", "the shared list is not modified") | |||
| 69 | + | } | |||
M internal/adapters/rpc/session.go +39 -2
| @@ -4,16 +4,28 @@ package rpc | |||
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "context" | |
| 7 | + | "encoding/base64" | |
| 8 | + | "errors" | |
| 9 | + | "fmt" | |
| 7 | 10 | ||
| 8 | 11 | "connectrpc.com/connect" | |
| 9 | 12 | ||
| 10 | 13 | pb "bnema.dev/ermite-api/gen/ermite" | |
| 14 | + | "bnema.dev/ermite/internal/domain" | |
| 11 | 15 | "bnema.dev/ermite/internal/ports" | |
| 12 | 16 | ) | |
| 13 | 17 | ||
| 18 | + | // errPasskeysUnavailable is what the passkey RPCs answer when the server runs | |
| 19 | + | // without passkeys (no https public URL with a DNS host name). | |
| 20 | + | var errPasskeysUnavailable = connect.NewError(connect.CodeFailedPrecondition, errors.New("passkeys are not available on this server")) | |
| 21 | + | ||
| 14 | 22 | // sessionService implements ermiteconnect.SessionServiceHandler. The browser | |
| 15 | - | // never calls it: sessions come from the link, and the API ignores cookies. | |
| 16 | - | type sessionService struct{ sessions ports.SessionService } | |
| 23 | + | // never calls it: sessions come from the link or a passkey, and the API | |
| 24 | + | // ignores cookies. passkeys is nil when passkeys are not available. | |
| 25 | + | type sessionService struct { | |
| 26 | + | sessions ports.SessionService | |
| 27 | + | passkeys ports.PasskeyService | |
| 28 | + | } | |
| 17 | 29 | ||
| 18 | 30 | func (s *sessionService) CreateLoginLink(ctx context.Context, _ *connect.Request[pb.CreateLoginLinkRequest]) (*connect.Response[pb.CreateLoginLinkResponse], error) { | |
| 19 | 31 | link, err := s.sessions.CreateLoginLink(ctx, caller(ctx)) | |
| @@ -29,3 +41,28 @@ func (s *sessionService) SignOutEverywhere(ctx context.Context, _ *connect.Reque | |||
| 29 | 41 | } | |
| 30 | 42 | return connect.NewResponse(&pb.SignOutEverywhereResponse{}), nil | |
| 31 | 43 | } | |
| 44 | + | ||
| 45 | + | func (s *sessionService) ListPasskeys(ctx context.Context, _ *connect.Request[pb.ListPasskeysRequest]) (*connect.Response[pb.ListPasskeysResponse], error) { | |
| 46 | + | if s.passkeys == nil { | |
| 47 | + | return nil, errPasskeysUnavailable | |
| 48 | + | } | |
| 49 | + | list, err := s.passkeys.List(ctx, caller(ctx)) | |
| 50 | + | if err != nil { | |
| 51 | + | return nil, connectError(ctx, err) | |
| 52 | + | } | |
| 53 | + | return connect.NewResponse(&pb.ListPasskeysResponse{Passkeys: passkeysToPB(list)}), nil | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | func (s *sessionService) RemovePasskey(ctx context.Context, req *connect.Request[pb.RemovePasskeyRequest]) (*connect.Response[pb.RemovePasskeyResponse], error) { | |
| 57 | + | if s.passkeys == nil { | |
| 58 | + | return nil, errPasskeysUnavailable | |
| 59 | + | } | |
| 60 | + | id, err := base64.RawURLEncoding.DecodeString(req.Msg.GetId()) | |
| 61 | + | if err != nil || len(id) == 0 { | |
| 62 | + | return nil, connectError(ctx, fmt.Errorf("%w: id is not a base64url credential id", domain.ErrInvalid)) | |
| 63 | + | } | |
| 64 | + | if err := s.passkeys.Remove(ctx, caller(ctx), id); err != nil { | |
| 65 | + | return nil, connectError(ctx, err) | |
| 66 | + | } | |
| 67 | + | return connect.NewResponse(&pb.RemovePasskeyResponse{}), nil | |
| 68 | + | } | |
| @@ -4,16 +4,28 @@ package rpc | |||||
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | "context" | 6 | "context" | ||
| 7 | + | "encoding/base64" | |||
| 8 | + | "errors" | |||
| 9 | + | "fmt" | |||
| 7 | 10 | ||||
| 8 | "connectrpc.com/connect" | 11 | "connectrpc.com/connect" | ||
| 9 | 12 | ||||
| 10 | pb "bnema.dev/ermite-api/gen/ermite" | 13 | pb "bnema.dev/ermite-api/gen/ermite" | ||
| 14 | + | "bnema.dev/ermite/internal/domain" | |||
| 11 | "bnema.dev/ermite/internal/ports" | 15 | "bnema.dev/ermite/internal/ports" | ||
| 12 | ) | 16 | ) | ||
| 13 | 17 | ||||
| 18 | + | // errPasskeysUnavailable is what the passkey RPCs answer when the server runs | |||
| 19 | + | // without passkeys (no https public URL with a DNS host name). | |||
| 20 | + | var errPasskeysUnavailable = connect.NewError(connect.CodeFailedPrecondition, errors.New("passkeys are not available on this server")) | |||
| 21 | + | ||||
| 14 | // sessionService implements ermiteconnect.SessionServiceHandler. The browser | 22 | // sessionService implements ermiteconnect.SessionServiceHandler. The browser | ||
| 15 | - | // never calls it: sessions come from the link, and the API ignores cookies. | 23 | + | // never calls it: sessions come from the link or a passkey, and the API |
| 16 | - | type sessionService struct{ sessions ports.SessionService } | 24 | + | // ignores cookies. passkeys is nil when passkeys are not available. |
| 25 | + | type sessionService struct { | |||
| 26 | + | sessions ports.SessionService | |||
| 27 | + | passkeys ports.PasskeyService | |||
| 28 | + | } | |||
| 17 | 29 | ||||
| 18 | func (s *sessionService) CreateLoginLink(ctx context.Context, _ *connect.Request[pb.CreateLoginLinkRequest]) (*connect.Response[pb.CreateLoginLinkResponse], error) { | 30 | func (s *sessionService) CreateLoginLink(ctx context.Context, _ *connect.Request[pb.CreateLoginLinkRequest]) (*connect.Response[pb.CreateLoginLinkResponse], error) { | ||
| 19 | link, err := s.sessions.CreateLoginLink(ctx, caller(ctx)) | 31 | link, err := s.sessions.CreateLoginLink(ctx, caller(ctx)) | ||
| @@ -29,3 +41,28 @@ func (s *sessionService) SignOutEverywhere(ctx context.Context, _ *connect.Reque | |||||
| 29 | } | 41 | } | ||
| 30 | return connect.NewResponse(&pb.SignOutEverywhereResponse{}), nil | 42 | return connect.NewResponse(&pb.SignOutEverywhereResponse{}), nil | ||
| 31 | } | 43 | } | ||
| 44 | + | ||||
| 45 | + | func (s *sessionService) ListPasskeys(ctx context.Context, _ *connect.Request[pb.ListPasskeysRequest]) (*connect.Response[pb.ListPasskeysResponse], error) { | |||
| 46 | + | if s.passkeys == nil { | |||
| 47 | + | return nil, errPasskeysUnavailable | |||
| 48 | + | } | |||
| 49 | + | list, err := s.passkeys.List(ctx, caller(ctx)) | |||
| 50 | + | if err != nil { | |||
| 51 | + | return nil, connectError(ctx, err) | |||
| 52 | + | } | |||
| 53 | + | return connect.NewResponse(&pb.ListPasskeysResponse{Passkeys: passkeysToPB(list)}), nil | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | func (s *sessionService) RemovePasskey(ctx context.Context, req *connect.Request[pb.RemovePasskeyRequest]) (*connect.Response[pb.RemovePasskeyResponse], error) { | |||
| 57 | + | if s.passkeys == nil { | |||
| 58 | + | return nil, errPasskeysUnavailable | |||
| 59 | + | } | |||
| 60 | + | id, err := base64.RawURLEncoding.DecodeString(req.Msg.GetId()) | |||
| 61 | + | if err != nil || len(id) == 0 { | |||
| 62 | + | return nil, connectError(ctx, fmt.Errorf("%w: id is not a base64url credential id", domain.ErrInvalid)) | |||
| 63 | + | } | |||
| 64 | + | if err := s.passkeys.Remove(ctx, caller(ctx), id); err != nil { | |||
| 65 | + | return nil, connectError(ctx, err) | |||
| 66 | + | } | |||
| 67 | + | return connect.NewResponse(&pb.RemovePasskeyResponse{}), nil | |||
| 68 | + | } | |||
M internal/adapters/rpc/session_test.go +97 -2
| @@ -3,6 +3,7 @@ | |||
| 3 | 3 | package rpc | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | + | "encoding/base64" | |
| 6 | 7 | "testing" | |
| 7 | 8 | "time" | |
| 8 | 9 | ||
| @@ -35,7 +36,7 @@ func TestSessionServiceCreateLoginLink(t *testing.T) { | |||
| 35 | 36 | sessions := mocks.NewSessionService(t) | |
| 36 | 37 | sessions.EXPECT().CreateLoginLink(mock.Anything, domain.Owner()). | |
| 37 | 38 | Return(ports.LoginLink{URL: "https://x/-/login/n", ExpiresAt: exp}, tt.usecase).Once() | |
| 38 | - | resp, err := (&sessionService{sessions}).CreateLoginLink(ownerCtx(), connect.NewRequest(&pb.CreateLoginLinkRequest{})) | |
| 39 | + | resp, err := (&sessionService{sessions: sessions}).CreateLoginLink(ownerCtx(), connect.NewRequest(&pb.CreateLoginLinkRequest{})) | |
| 39 | 40 | if tt.wantCode != 0 { | |
| 40 | 41 | requireCode(t, tt.wantCode, err) | |
| 41 | 42 | return | |
| @@ -62,7 +63,7 @@ func TestSessionServiceSignOutEverywhere(t *testing.T) { | |||
| 62 | 63 | t.Parallel() | |
| 63 | 64 | sessions := mocks.NewSessionService(t) | |
| 64 | 65 | sessions.EXPECT().SignOutEverywhere(mock.Anything, domain.Owner()).Return(tt.usecase).Once() | |
| 65 | - | _, err := (&sessionService{sessions}).SignOutEverywhere(ownerCtx(), connect.NewRequest(&pb.SignOutEverywhereRequest{})) | |
| 66 | + | _, err := (&sessionService{sessions: sessions}).SignOutEverywhere(ownerCtx(), connect.NewRequest(&pb.SignOutEverywhereRequest{})) | |
| 66 | 67 | if tt.wantCode != 0 { | |
| 67 | 68 | requireCode(t, tt.wantCode, err) | |
| 68 | 69 | return | |
| @@ -71,3 +72,97 @@ func TestSessionServiceSignOutEverywhere(t *testing.T) { | |||
| 71 | 72 | }) | |
| 72 | 73 | } | |
| 73 | 74 | } | |
| 75 | + | ||
| 76 | + | func TestSessionServiceListPasskeys(t *testing.T) { | |
| 77 | + | t.Parallel() | |
| 78 | + | created := time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC) | |
| 79 | + | used := created.Add(time.Hour) | |
| 80 | + | tests := []struct { | |
| 81 | + | name string | |
| 82 | + | list []domain.Passkey | |
| 83 | + | usecase error | |
| 84 | + | wantCode connect.Code | |
| 85 | + | }{ | |
| 86 | + | {name: "none", list: nil}, | |
| 87 | + | {name: "two", list: []domain.Passkey{ | |
| 88 | + | {ID: []byte{0xfb, 0xff, 0x01}, Name: "phone", PublicKey: []byte("secret"), CreatedAt: created, LastUsedAt: used, BackupState: true}, | |
| 89 | + | {ID: []byte("hw"), Name: "key", CreatedAt: created}, | |
| 90 | + | }}, | |
| 91 | + | {name: "not the owner", usecase: domain.ErrForbidden, wantCode: connect.CodePermissionDenied}, | |
| 92 | + | {name: "anonymous", usecase: domain.ErrUnauthenticated, wantCode: connect.CodeUnauthenticated}, | |
| 93 | + | } | |
| 94 | + | for _, tt := range tests { | |
| 95 | + | t.Run(tt.name, func(t *testing.T) { | |
| 96 | + | t.Parallel() | |
| 97 | + | passkeys := mocks.NewPasskeyService(t) | |
| 98 | + | passkeys.EXPECT().List(mock.Anything, domain.Owner()).Return(tt.list, tt.usecase).Once() | |
| 99 | + | resp, err := (&sessionService{passkeys: passkeys}).ListPasskeys(ownerCtx(), connect.NewRequest(&pb.ListPasskeysRequest{})) | |
| 100 | + | if tt.wantCode != 0 { | |
| 101 | + | requireCode(t, tt.wantCode, err) | |
| 102 | + | return | |
| 103 | + | } | |
| 104 | + | require.NoError(t, err) | |
| 105 | + | require.Len(t, resp.Msg.GetPasskeys(), len(tt.list)) | |
| 106 | + | for i, want := range tt.list { | |
| 107 | + | got := resp.Msg.GetPasskeys()[i] | |
| 108 | + | assert.Equal(t, base64.RawURLEncoding.EncodeToString(want.ID), got.GetId()) | |
| 109 | + | assert.Equal(t, want.Name, got.GetName()) | |
| 110 | + | assert.Equal(t, want.BackupState, got.GetSynced()) | |
| 111 | + | assert.True(t, want.CreatedAt.Equal(got.GetCreatedAt().AsTime())) | |
| 112 | + | if want.LastUsedAt.IsZero() { | |
| 113 | + | assert.Nil(t, got.GetLastUsedAt(), "never used") | |
| 114 | + | } else { | |
| 115 | + | assert.True(t, want.LastUsedAt.Equal(got.GetLastUsedAt().AsTime())) | |
| 116 | + | } | |
| 117 | + | } | |
| 118 | + | }) | |
| 119 | + | } | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | func TestSessionServiceRemovePasskey(t *testing.T) { | |
| 123 | + | t.Parallel() | |
| 124 | + | id := []byte{0xfb, 0xff, 0x01, 'x'} | |
| 125 | + | enc := base64.RawURLEncoding.EncodeToString(id) | |
| 126 | + | tests := []struct { | |
| 127 | + | name string | |
| 128 | + | id string | |
| 129 | + | usecase error | |
| 130 | + | called bool | |
| 131 | + | wantCode connect.Code | |
| 132 | + | }{ | |
| 133 | + | {name: "ok", id: enc, called: true}, | |
| 134 | + | {name: "unknown id", id: enc, called: true, usecase: domain.ErrNotFound, wantCode: connect.CodeNotFound}, | |
| 135 | + | {name: "not the owner", id: enc, called: true, usecase: domain.ErrForbidden, wantCode: connect.CodePermissionDenied}, | |
| 136 | + | {name: "anonymous", id: enc, called: true, usecase: domain.ErrUnauthenticated, wantCode: connect.CodeUnauthenticated}, | |
| 137 | + | {name: "not base64url", id: "not base64!", wantCode: connect.CodeInvalidArgument}, | |
| 138 | + | {name: "padded base64", id: base64.URLEncoding.EncodeToString(id), wantCode: connect.CodeInvalidArgument}, | |
| 139 | + | {name: "standard alphabet", id: "+/8B", wantCode: connect.CodeInvalidArgument}, | |
| 140 | + | {name: "empty", id: "", wantCode: connect.CodeInvalidArgument}, | |
| 141 | + | } | |
| 142 | + | for _, tt := range tests { | |
| 143 | + | t.Run(tt.name, func(t *testing.T) { | |
| 144 | + | t.Parallel() | |
| 145 | + | passkeys := mocks.NewPasskeyService(t) | |
| 146 | + | if tt.called { | |
| 147 | + | passkeys.EXPECT().Remove(mock.Anything, domain.Owner(), id).Return(tt.usecase).Once() | |
| 148 | + | } | |
| 149 | + | _, err := (&sessionService{passkeys: passkeys}).RemovePasskey(ownerCtx(), connect.NewRequest(&pb.RemovePasskeyRequest{Id: tt.id})) | |
| 150 | + | if tt.wantCode != 0 { | |
| 151 | + | requireCode(t, tt.wantCode, err) | |
| 152 | + | return | |
| 153 | + | } | |
| 154 | + | require.NoError(t, err) | |
| 155 | + | }) | |
| 156 | + | } | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | // Without passkeys the RPCs answer failed_precondition and call nothing. | |
| 160 | + | func TestSessionServicePasskeysUnavailable(t *testing.T) { | |
| 161 | + | t.Parallel() | |
| 162 | + | svc := &sessionService{sessions: mocks.NewSessionService(t)} | |
| 163 | + | _, err := svc.ListPasskeys(ownerCtx(), connect.NewRequest(&pb.ListPasskeysRequest{})) | |
| 164 | + | requireCode(t, connect.CodeFailedPrecondition, err) | |
| 165 | + | assert.Contains(t, err.Error(), "passkeys are not available on this server") | |
| 166 | + | _, err = svc.RemovePasskey(ownerCtx(), connect.NewRequest(&pb.RemovePasskeyRequest{Id: "AQ"})) | |
| 167 | + | requireCode(t, connect.CodeFailedPrecondition, err) | |
| 168 | + | } | |
| @@ -3,6 +3,7 @@ | |||||
| 3 | package rpc | 3 | package rpc | ||
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | + | "encoding/base64" | |||
| 6 | "testing" | 7 | "testing" | ||
| 7 | "time" | 8 | "time" | ||
| 8 | 9 | ||||
| @@ -35,7 +36,7 @@ func TestSessionServiceCreateLoginLink(t *testing.T) { | |||||
| 35 | sessions := mocks.NewSessionService(t) | 36 | sessions := mocks.NewSessionService(t) | ||
| 36 | sessions.EXPECT().CreateLoginLink(mock.Anything, domain.Owner()). | 37 | sessions.EXPECT().CreateLoginLink(mock.Anything, domain.Owner()). | ||
| 37 | Return(ports.LoginLink{URL: "https://x/-/login/n", ExpiresAt: exp}, tt.usecase).Once() | 38 | Return(ports.LoginLink{URL: "https://x/-/login/n", ExpiresAt: exp}, tt.usecase).Once() | ||
| 38 | - | resp, err := (&sessionService{sessions}).CreateLoginLink(ownerCtx(), connect.NewRequest(&pb.CreateLoginLinkRequest{})) | 39 | + | resp, err := (&sessionService{sessions: sessions}).CreateLoginLink(ownerCtx(), connect.NewRequest(&pb.CreateLoginLinkRequest{})) |
| 39 | if tt.wantCode != 0 { | 40 | if tt.wantCode != 0 { | ||
| 40 | requireCode(t, tt.wantCode, err) | 41 | requireCode(t, tt.wantCode, err) | ||
| 41 | return | 42 | return | ||
| @@ -62,7 +63,7 @@ func TestSessionServiceSignOutEverywhere(t *testing.T) { | |||||
| 62 | t.Parallel() | 63 | t.Parallel() | ||
| 63 | sessions := mocks.NewSessionService(t) | 64 | sessions := mocks.NewSessionService(t) | ||
| 64 | sessions.EXPECT().SignOutEverywhere(mock.Anything, domain.Owner()).Return(tt.usecase).Once() | 65 | sessions.EXPECT().SignOutEverywhere(mock.Anything, domain.Owner()).Return(tt.usecase).Once() | ||
| 65 | - | _, err := (&sessionService{sessions}).SignOutEverywhere(ownerCtx(), connect.NewRequest(&pb.SignOutEverywhereRequest{})) | 66 | + | _, err := (&sessionService{sessions: sessions}).SignOutEverywhere(ownerCtx(), connect.NewRequest(&pb.SignOutEverywhereRequest{})) |
| 66 | if tt.wantCode != 0 { | 67 | if tt.wantCode != 0 { | ||
| 67 | requireCode(t, tt.wantCode, err) | 68 | requireCode(t, tt.wantCode, err) | ||
| 68 | return | 69 | return | ||
| @@ -71,3 +72,97 @@ func TestSessionServiceSignOutEverywhere(t *testing.T) { | |||||
| 71 | }) | 72 | }) | ||
| 72 | } | 73 | } | ||
| 73 | } | 74 | } | ||
| 75 | + | ||||
| 76 | + | func TestSessionServiceListPasskeys(t *testing.T) { | |||
| 77 | + | t.Parallel() | |||
| 78 | + | created := time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC) | |||
| 79 | + | used := created.Add(time.Hour) | |||
| 80 | + | tests := []struct { | |||
| 81 | + | name string | |||
| 82 | + | list []domain.Passkey | |||
| 83 | + | usecase error | |||
| 84 | + | wantCode connect.Code | |||
| 85 | + | }{ | |||
| 86 | + | {name: "none", list: nil}, | |||
| 87 | + | {name: "two", list: []domain.Passkey{ | |||
| 88 | + | {ID: []byte{0xfb, 0xff, 0x01}, Name: "phone", PublicKey: []byte("secret"), CreatedAt: created, LastUsedAt: used, BackupState: true}, | |||
| 89 | + | {ID: []byte("hw"), Name: "key", CreatedAt: created}, | |||
| 90 | + | }}, | |||
| 91 | + | {name: "not the owner", usecase: domain.ErrForbidden, wantCode: connect.CodePermissionDenied}, | |||
| 92 | + | {name: "anonymous", usecase: domain.ErrUnauthenticated, wantCode: connect.CodeUnauthenticated}, | |||
| 93 | + | } | |||
| 94 | + | for _, tt := range tests { | |||
| 95 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 96 | + | t.Parallel() | |||
| 97 | + | passkeys := mocks.NewPasskeyService(t) | |||
| 98 | + | passkeys.EXPECT().List(mock.Anything, domain.Owner()).Return(tt.list, tt.usecase).Once() | |||
| 99 | + | resp, err := (&sessionService{passkeys: passkeys}).ListPasskeys(ownerCtx(), connect.NewRequest(&pb.ListPasskeysRequest{})) | |||
| 100 | + | if tt.wantCode != 0 { | |||
| 101 | + | requireCode(t, tt.wantCode, err) | |||
| 102 | + | return | |||
| 103 | + | } | |||
| 104 | + | require.NoError(t, err) | |||
| 105 | + | require.Len(t, resp.Msg.GetPasskeys(), len(tt.list)) | |||
| 106 | + | for i, want := range tt.list { | |||
| 107 | + | got := resp.Msg.GetPasskeys()[i] | |||
| 108 | + | assert.Equal(t, base64.RawURLEncoding.EncodeToString(want.ID), got.GetId()) | |||
| 109 | + | assert.Equal(t, want.Name, got.GetName()) | |||
| 110 | + | assert.Equal(t, want.BackupState, got.GetSynced()) | |||
| 111 | + | assert.True(t, want.CreatedAt.Equal(got.GetCreatedAt().AsTime())) | |||
| 112 | + | if want.LastUsedAt.IsZero() { | |||
| 113 | + | assert.Nil(t, got.GetLastUsedAt(), "never used") | |||
| 114 | + | } else { | |||
| 115 | + | assert.True(t, want.LastUsedAt.Equal(got.GetLastUsedAt().AsTime())) | |||
| 116 | + | } | |||
| 117 | + | } | |||
| 118 | + | }) | |||
| 119 | + | } | |||
| 120 | + | } | |||
| 121 | + | ||||
| 122 | + | func TestSessionServiceRemovePasskey(t *testing.T) { | |||
| 123 | + | t.Parallel() | |||
| 124 | + | id := []byte{0xfb, 0xff, 0x01, 'x'} | |||
| 125 | + | enc := base64.RawURLEncoding.EncodeToString(id) | |||
| 126 | + | tests := []struct { | |||
| 127 | + | name string | |||
| 128 | + | id string | |||
| 129 | + | usecase error | |||
| 130 | + | called bool | |||
| 131 | + | wantCode connect.Code | |||
| 132 | + | }{ | |||
| 133 | + | {name: "ok", id: enc, called: true}, | |||
| 134 | + | {name: "unknown id", id: enc, called: true, usecase: domain.ErrNotFound, wantCode: connect.CodeNotFound}, | |||
| 135 | + | {name: "not the owner", id: enc, called: true, usecase: domain.ErrForbidden, wantCode: connect.CodePermissionDenied}, | |||
| 136 | + | {name: "anonymous", id: enc, called: true, usecase: domain.ErrUnauthenticated, wantCode: connect.CodeUnauthenticated}, | |||
| 137 | + | {name: "not base64url", id: "not base64!", wantCode: connect.CodeInvalidArgument}, | |||
| 138 | + | {name: "padded base64", id: base64.URLEncoding.EncodeToString(id), wantCode: connect.CodeInvalidArgument}, | |||
| 139 | + | {name: "standard alphabet", id: "+/8B", wantCode: connect.CodeInvalidArgument}, | |||
| 140 | + | {name: "empty", id: "", wantCode: connect.CodeInvalidArgument}, | |||
| 141 | + | } | |||
| 142 | + | for _, tt := range tests { | |||
| 143 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 144 | + | t.Parallel() | |||
| 145 | + | passkeys := mocks.NewPasskeyService(t) | |||
| 146 | + | if tt.called { | |||
| 147 | + | passkeys.EXPECT().Remove(mock.Anything, domain.Owner(), id).Return(tt.usecase).Once() | |||
| 148 | + | } | |||
| 149 | + | _, err := (&sessionService{passkeys: passkeys}).RemovePasskey(ownerCtx(), connect.NewRequest(&pb.RemovePasskeyRequest{Id: tt.id})) | |||
| 150 | + | if tt.wantCode != 0 { | |||
| 151 | + | requireCode(t, tt.wantCode, err) | |||
| 152 | + | return | |||
| 153 | + | } | |||
| 154 | + | require.NoError(t, err) | |||
| 155 | + | }) | |||
| 156 | + | } | |||
| 157 | + | } | |||
| 158 | + | ||||
| 159 | + | // Without passkeys the RPCs answer failed_precondition and call nothing. | |||
| 160 | + | func TestSessionServicePasskeysUnavailable(t *testing.T) { | |||
| 161 | + | t.Parallel() | |||
| 162 | + | svc := &sessionService{sessions: mocks.NewSessionService(t)} | |||
| 163 | + | _, err := svc.ListPasskeys(ownerCtx(), connect.NewRequest(&pb.ListPasskeysRequest{})) | |||
| 164 | + | requireCode(t, connect.CodeFailedPrecondition, err) | |||
| 165 | + | assert.Contains(t, err.Error(), "passkeys are not available on this server") | |||
| 166 | + | _, err = svc.RemovePasskey(ownerCtx(), connect.NewRequest(&pb.RemovePasskeyRequest{Id: "AQ"})) | |||
| 167 | + | requireCode(t, connect.CodeFailedPrecondition, err) | |||
| 168 | + | } | |||
M internal/app/cli_e2e_test.go +7 -4
| @@ -309,9 +309,11 @@ func TestAC6CLIEndToEnd(t *testing.T) { | |||
| 309 | 309 | res = c.cli(t, "", "repo", "list", "--json").ok(t) | |
| 310 | 310 | assert.JSONEq(t, `[]`, res.stdout) | |
| 311 | 311 | ||
| 312 | - | // version: no token needed, nothing missing. | |
| 312 | + | // version: no token needed. This server runs on http://127.0.0.1, where | |
| 313 | + | // passkeys are not available: they are the only feature missing. | |
| 313 | 314 | res = c.cli(t, "", "version", "--json").ok(t) | |
| 314 | - | assert.Empty(t, res.stderr, "no missing-feature warning") | |
| 315 | + | assert.Equal(t, 1, strings.Count(res.stderr, "warning:"), res.stderr) | |
| 316 | + | assert.Contains(t, res.stderr, "the server does not support passkeys") | |
| 315 | 317 | type versionOut struct { | |
| 316 | 318 | Client struct{ Version string } | |
| 317 | 319 | Server struct { | |
| @@ -325,11 +327,12 @@ func TestAC6CLIEndToEnd(t *testing.T) { | |||
| 325 | 327 | assert.Equal(t, c.url, ver.Server.URL) | |
| 326 | 328 | assert.NotEmpty(t, ver.Server.Version) | |
| 327 | 329 | assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths"}, ver.Server.Features) | |
| 328 | - | assert.Equal(t, []string{}, ver.Server.MissingFeatures) | |
| 330 | + | assert.Equal(t, []string{"passkeys"}, ver.Server.MissingFeatures) | |
| 329 | 331 | stdout, stderr, code = c.bin(t, secret, "version") | |
| 330 | 332 | require.Equal(t, 0, code, stderr) | |
| 331 | 333 | assert.Contains(t, stdout, "server") | |
| 332 | - | assert.Empty(t, stderr) | |
| 334 | + | assert.Equal(t, 1, strings.Count(stderr, "warning:"), stderr) | |
| 335 | + | assert.Contains(t, stderr, "the server does not support passkeys") | |
| 333 | 336 | ||
| 334 | 337 | // Tokens: create a second one, list, revoke it, see it stop working. | |
| 335 | 338 | // (through the binary: the in-process runs use a fixed clock) | |
| @@ -309,9 +309,11 @@ func TestAC6CLIEndToEnd(t *testing.T) { | |||||
| 309 | res = c.cli(t, "", "repo", "list", "--json").ok(t) | 309 | res = c.cli(t, "", "repo", "list", "--json").ok(t) | ||
| 310 | assert.JSONEq(t, `[]`, res.stdout) | 310 | assert.JSONEq(t, `[]`, res.stdout) | ||
| 311 | 311 | ||||
| 312 | - | // version: no token needed, nothing missing. | 312 | + | // version: no token needed. This server runs on http://127.0.0.1, where |
| 313 | + | // passkeys are not available: they are the only feature missing. | |||
| 313 | res = c.cli(t, "", "version", "--json").ok(t) | 314 | res = c.cli(t, "", "version", "--json").ok(t) | ||
| 314 | - | assert.Empty(t, res.stderr, "no missing-feature warning") | 315 | + | assert.Equal(t, 1, strings.Count(res.stderr, "warning:"), res.stderr) |
| 316 | + | assert.Contains(t, res.stderr, "the server does not support passkeys") | |||
| 315 | type versionOut struct { | 317 | type versionOut struct { | ||
| 316 | Client struct{ Version string } | 318 | Client struct{ Version string } | ||
| 317 | Server struct { | 319 | Server struct { | ||
| @@ -325,11 +327,12 @@ func TestAC6CLIEndToEnd(t *testing.T) { | |||||
| 325 | assert.Equal(t, c.url, ver.Server.URL) | 327 | assert.Equal(t, c.url, ver.Server.URL) | ||
| 326 | assert.NotEmpty(t, ver.Server.Version) | 328 | assert.NotEmpty(t, ver.Server.Version) | ||
| 327 | assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths"}, ver.Server.Features) | 329 | assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths"}, ver.Server.Features) | ||
| 328 | - | assert.Equal(t, []string{}, ver.Server.MissingFeatures) | 330 | + | assert.Equal(t, []string{"passkeys"}, ver.Server.MissingFeatures) |
| 329 | stdout, stderr, code = c.bin(t, secret, "version") | 331 | stdout, stderr, code = c.bin(t, secret, "version") | ||
| 330 | require.Equal(t, 0, code, stderr) | 332 | require.Equal(t, 0, code, stderr) | ||
| 331 | assert.Contains(t, stdout, "server") | 333 | assert.Contains(t, stdout, "server") | ||
| 332 | - | assert.Empty(t, stderr) | 334 | + | assert.Equal(t, 1, strings.Count(stderr, "warning:"), stderr) |
| 335 | + | assert.Contains(t, stderr, "the server does not support passkeys") | |||
| 333 | 336 | ||||
| 334 | // Tokens: create a second one, list, revoke it, see it stop working. | 337 | // Tokens: create a second one, list, revoke it, see it stop working. | ||
| 335 | // (through the binary: the in-process runs use a fixed clock) | 338 | // (through the binary: the in-process runs use a fixed clock) | ||
M internal/app/cli_version.go +1 -0
| @@ -106,4 +106,5 @@ var featureUse = map[string]string{ | |||
| 106 | 106 | client.FeatureMergeDeleteBranch: "`ermite mr merge --delete-branch` needs: the merge works but the branch is kept", | |
| 107 | 107 | client.FeatureSigningKey: "`ermite signing-key show` needs: the command will fail on this server", | |
| 108 | 108 | client.FeatureSession: "`ermite web login` needs: the command will fail on this server", | |
| 109 | + | client.FeaturePasskeys: "`ermite web passkey` needs: the commands will fail on this server (passkeys need an https public URL with a DNS host name)", | |
| 109 | 110 | } | |
| @@ -106,4 +106,5 @@ var featureUse = map[string]string{ | |||||
| 106 | client.FeatureMergeDeleteBranch: "`ermite mr merge --delete-branch` needs: the merge works but the branch is kept", | 106 | client.FeatureMergeDeleteBranch: "`ermite mr merge --delete-branch` needs: the merge works but the branch is kept", | ||
| 107 | client.FeatureSigningKey: "`ermite signing-key show` needs: the command will fail on this server", | 107 | client.FeatureSigningKey: "`ermite signing-key show` needs: the command will fail on this server", | ||
| 108 | client.FeatureSession: "`ermite web login` needs: the command will fail on this server", | 108 | client.FeatureSession: "`ermite web login` needs: the command will fail on this server", | ||
| 109 | + | client.FeaturePasskeys: "`ermite web passkey` needs: the commands will fail on this server (passkeys need an https public URL with a DNS host name)", | |||
| 109 | } | 110 | } | ||
M internal/app/cli_version_test.go +20 -5
| @@ -4,6 +4,7 @@ package app | |||
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "errors" | |
| 7 | + | "strings" | |
| 7 | 8 | "testing" | |
| 8 | 9 | ||
| 9 | 10 | "connectrpc.com/connect" | |
| @@ -14,7 +15,7 @@ import ( | |||
| 14 | 15 | ) | |
| 15 | 16 | ||
| 16 | 17 | // allFeatures are the features this client uses. | |
| 17 | - | var allFeatures = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session"} | |
| 18 | + | var allFeatures = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "passkeys"} | |
| 18 | 19 | ||
| 19 | 20 | func versionFake(features ...string) *fakeAPI { | |
| 20 | 21 | return &fakeAPI{getVersion: func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) { | |
| @@ -35,15 +36,15 @@ func TestVersionWithServer(t *testing.T) { | |||
| 35 | 36 | f := versionFake(allFeatures...) | |
| 36 | 37 | url := f.serve(t) | |
| 37 | 38 | res := runCLI(t, envFor(url), "", "version").ok(t) | |
| 38 | - | assert.Equal(t, "client ermite v0.0.1\nserver v0.4.0 at "+url+"\nfeatures repo.update, events.resume, merge_requests, merge_requests.delete_branch, signing_key, session\n", res.stdout) | |
| 39 | + | assert.Equal(t, "client ermite v0.0.1\nserver v0.4.0 at "+url+"\nfeatures repo.update, events.resume, merge_requests, merge_requests.delete_branch, signing_key, session, passkeys\n", res.stdout) | |
| 39 | 40 | assert.Empty(t, res.stderr, "no warning when every feature is there") | |
| 40 | 41 | ||
| 41 | 42 | res = runCLI(t, envFor(url), "", "version", "--json").ok(t) | |
| 42 | - | assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","events.resume","merge_requests","merge_requests.delete_branch","signing_key","session"],"missing_features":[]}}`, res.stdout) | |
| 43 | + | assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","events.resume","merge_requests","merge_requests.delete_branch","signing_key","session","passkeys"],"missing_features":[]}}`, res.stdout) | |
| 43 | 44 | } | |
| 44 | 45 | ||
| 45 | 46 | func TestVersionWarnsAboutMissingFeatures(t *testing.T) { | |
| 46 | - | f := versionFake("repo.update", "merge_requests", "merge_requests.delete_branch", "signing_key", "session") | |
| 47 | + | f := versionFake("repo.update", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "passkeys") | |
| 47 | 48 | url := f.serve(t) | |
| 48 | 49 | res := runCLI(t, envFor(url), "", "version").ok(t) | |
| 49 | 50 | assert.Contains(t, res.stdout, "v0.4.0") | |
| @@ -51,13 +52,27 @@ func TestVersionWarnsAboutMissingFeatures(t *testing.T) { | |||
| 51 | 52 | assert.NotContains(t, res.stderr, "repo.update") | |
| 52 | 53 | ||
| 53 | 54 | res = runCLI(t, envFor(url), "", "version", "--json").ok(t) | |
| 54 | - | assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","merge_requests","merge_requests.delete_branch","signing_key","session"],"missing_features":["events.resume"]}}`, res.stdout) | |
| 55 | + | assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","merge_requests","merge_requests.delete_branch","signing_key","session","passkeys"],"missing_features":["events.resume"]}}`, res.stdout) | |
| 55 | 56 | assert.Contains(t, res.stderr, "events.resume", "the warning also goes to stderr with --json") | |
| 56 | 57 | ||
| 57 | 58 | f = versionFake() | |
| 58 | 59 | res = runCLI(t, envFor(f.serve(t)), "", "version").ok(t) | |
| 59 | 60 | assert.Contains(t, res.stderr, "repo.update") | |
| 60 | 61 | assert.Contains(t, res.stderr, "events.resume") | |
| 62 | + | assert.Contains(t, res.stderr, "passkeys") | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | // A server without passkeys (http public URL, or an IP address host) works for | |
| 66 | + | // everything else: only `ermite web passkey` is warned about. | |
| 67 | + | func TestVersionWarnsAboutMissingPasskeys(t *testing.T) { | |
| 68 | + | f := versionFake(allFeatures[:len(allFeatures)-1]...) | |
| 69 | + | res := runCLI(t, envFor(f.serve(t)), "", "version", "--json").ok(t) | |
| 70 | + | assert.Contains(t, res.stdout, `"missing_features": [ | |
| 71 | + | "passkeys" | |
| 72 | + | ]`) | |
| 73 | + | assert.Equal(t, 1, strings.Count(res.stderr, "warning:"), res.stderr) | |
| 74 | + | assert.Contains(t, res.stderr, "the server does not support passkeys, which `ermite web passkey` needs") | |
| 75 | + | assert.Contains(t, res.stderr, "https public URL") | |
| 61 | 76 | } | |
| 62 | 77 | ||
| 63 | 78 | func TestVersionServerFailureStillShowsClient(t *testing.T) { | |
| @@ -4,6 +4,7 @@ package app | |||||
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | "errors" | 6 | "errors" | ||
| 7 | + | "strings" | |||
| 7 | "testing" | 8 | "testing" | ||
| 8 | 9 | ||||
| 9 | "connectrpc.com/connect" | 10 | "connectrpc.com/connect" | ||
| @@ -14,7 +15,7 @@ import ( | |||||
| 14 | ) | 15 | ) | ||
| 15 | 16 | ||||
| 16 | // allFeatures are the features this client uses. | 17 | // allFeatures are the features this client uses. | ||
| 17 | - | var allFeatures = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session"} | 18 | + | var allFeatures = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "passkeys"} |
| 18 | 19 | ||||
| 19 | func versionFake(features ...string) *fakeAPI { | 20 | func versionFake(features ...string) *fakeAPI { | ||
| 20 | return &fakeAPI{getVersion: func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) { | 21 | return &fakeAPI{getVersion: func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) { | ||
| @@ -35,15 +36,15 @@ func TestVersionWithServer(t *testing.T) { | |||||
| 35 | f := versionFake(allFeatures...) | 36 | f := versionFake(allFeatures...) | ||
| 36 | url := f.serve(t) | 37 | url := f.serve(t) | ||
| 37 | res := runCLI(t, envFor(url), "", "version").ok(t) | 38 | res := runCLI(t, envFor(url), "", "version").ok(t) | ||
| 38 | - | assert.Equal(t, "client ermite v0.0.1\nserver v0.4.0 at "+url+"\nfeatures repo.update, events.resume, merge_requests, merge_requests.delete_branch, signing_key, session\n", res.stdout) | 39 | + | assert.Equal(t, "client ermite v0.0.1\nserver v0.4.0 at "+url+"\nfeatures repo.update, events.resume, merge_requests, merge_requests.delete_branch, signing_key, session, passkeys\n", res.stdout) |
| 39 | assert.Empty(t, res.stderr, "no warning when every feature is there") | 40 | assert.Empty(t, res.stderr, "no warning when every feature is there") | ||
| 40 | 41 | ||||
| 41 | res = runCLI(t, envFor(url), "", "version", "--json").ok(t) | 42 | res = runCLI(t, envFor(url), "", "version", "--json").ok(t) | ||
| 42 | - | assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","events.resume","merge_requests","merge_requests.delete_branch","signing_key","session"],"missing_features":[]}}`, res.stdout) | 43 | + | assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","events.resume","merge_requests","merge_requests.delete_branch","signing_key","session","passkeys"],"missing_features":[]}}`, res.stdout) |
| 43 | } | 44 | } | ||
| 44 | 45 | ||||
| 45 | func TestVersionWarnsAboutMissingFeatures(t *testing.T) { | 46 | func TestVersionWarnsAboutMissingFeatures(t *testing.T) { | ||
| 46 | - | f := versionFake("repo.update", "merge_requests", "merge_requests.delete_branch", "signing_key", "session") | 47 | + | f := versionFake("repo.update", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "passkeys") |
| 47 | url := f.serve(t) | 48 | url := f.serve(t) | ||
| 48 | res := runCLI(t, envFor(url), "", "version").ok(t) | 49 | res := runCLI(t, envFor(url), "", "version").ok(t) | ||
| 49 | assert.Contains(t, res.stdout, "v0.4.0") | 50 | assert.Contains(t, res.stdout, "v0.4.0") | ||
| @@ -51,13 +52,27 @@ func TestVersionWarnsAboutMissingFeatures(t *testing.T) { | |||||
| 51 | assert.NotContains(t, res.stderr, "repo.update") | 52 | assert.NotContains(t, res.stderr, "repo.update") | ||
| 52 | 53 | ||||
| 53 | res = runCLI(t, envFor(url), "", "version", "--json").ok(t) | 54 | res = runCLI(t, envFor(url), "", "version", "--json").ok(t) | ||
| 54 | - | assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","merge_requests","merge_requests.delete_branch","signing_key","session"],"missing_features":["events.resume"]}}`, res.stdout) | 55 | + | assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","merge_requests","merge_requests.delete_branch","signing_key","session","passkeys"],"missing_features":["events.resume"]}}`, res.stdout) |
| 55 | assert.Contains(t, res.stderr, "events.resume", "the warning also goes to stderr with --json") | 56 | assert.Contains(t, res.stderr, "events.resume", "the warning also goes to stderr with --json") | ||
| 56 | 57 | ||||
| 57 | f = versionFake() | 58 | f = versionFake() | ||
| 58 | res = runCLI(t, envFor(f.serve(t)), "", "version").ok(t) | 59 | res = runCLI(t, envFor(f.serve(t)), "", "version").ok(t) | ||
| 59 | assert.Contains(t, res.stderr, "repo.update") | 60 | assert.Contains(t, res.stderr, "repo.update") | ||
| 60 | assert.Contains(t, res.stderr, "events.resume") | 61 | assert.Contains(t, res.stderr, "events.resume") | ||
| 62 | + | assert.Contains(t, res.stderr, "passkeys") | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | // A server without passkeys (http public URL, or an IP address host) works for | |||
| 66 | + | // everything else: only `ermite web passkey` is warned about. | |||
| 67 | + | func TestVersionWarnsAboutMissingPasskeys(t *testing.T) { | |||
| 68 | + | f := versionFake(allFeatures[:len(allFeatures)-1]...) | |||
| 69 | + | res := runCLI(t, envFor(f.serve(t)), "", "version", "--json").ok(t) | |||
| 70 | + | assert.Contains(t, res.stdout, `"missing_features": [ | |||
| 71 | + | "passkeys" | |||
| 72 | + | ]`) | |||
| 73 | + | assert.Equal(t, 1, strings.Count(res.stderr, "warning:"), res.stderr) | |||
| 74 | + | assert.Contains(t, res.stderr, "the server does not support passkeys, which `ermite web passkey` needs") | |||
| 75 | + | assert.Contains(t, res.stderr, "https public URL") | |||
| 61 | } | 76 | } | ||
| 62 | 77 | ||||
| 63 | func TestVersionServerFailureStillShowsClient(t *testing.T) { | 78 | func TestVersionServerFailureStillShowsClient(t *testing.T) { | ||