1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package app
4
5import (
6 "errors"
7 "testing"
8
9 "connectrpc.com/connect"
10 "github.com/stretchr/testify/assert"
11 "github.com/stretchr/testify/require"
12
13 "bnema.dev/ermite-api/gen/ermite"
14)
15
16func twoPasskeys() *ermite.ListPasskeysResponse {
17 return &ermite.ListPasskeysResponse{Passkeys: []*ermite.Passkey{
18 {Id: "AQID", Name: "Pixel 9", CreatedAt: ts("2026-10-01T08:00:00Z"), LastUsedAt: ts("2026-10-09T18:30:00Z"), Synced: true},
19 {Id: "BAUG", Name: "yubikey", CreatedAt: ts("2026-10-02T09:15:00Z")},
20 }}
21}
22
23func TestWebPasskeyList(t *testing.T) {
24 f := &fakeAPI{listPasskeys: func(*ermite.ListPasskeysRequest) (*ermite.ListPasskeysResponse, error) { return twoPasskeys(), nil }}
25 res := runCLI(t, envFor(f.serve(t)), "", "web", "passkey", "list").ok(t)
26 assert.Empty(t, res.stderr)
27 assert.Regexp(t, `(?m)^ID\s+NAME\s+CREATED\s+LAST USED\s+SYNCED$`, res.stdout)
28 assert.Regexp(t, `(?m)^AQID\s+Pixel 9\s+2026-10-01 08:00\s+2026-10-09 18:30\s+yes$`, res.stdout)
29 assert.Regexp(t, `(?m)^BAUG\s+yubikey\s+2026-10-02 09:15\s+never\s+no$`, res.stdout)
30}
31
32func TestWebPasskeyListEmpty(t *testing.T) {
33 f := &fakeAPI{listPasskeys: func(*ermite.ListPasskeysRequest) (*ermite.ListPasskeysResponse, error) {
34 return &ermite.ListPasskeysResponse{}, nil
35 }}
36 url := f.serve(t)
37 res := runCLI(t, envFor(url), "", "web", "passkey", "list").ok(t)
38 assert.Empty(t, res.stdout)
39 assert.Contains(t, res.stderr, "no passkeys")
40
41 res = runCLI(t, envFor(url), "", "web", "passkey", "list", "--json").ok(t)
42 assert.JSONEq(t, `[]`, res.stdout)
43}
44
45func TestWebPasskeyListJSON(t *testing.T) {
46 f := &fakeAPI{listPasskeys: func(*ermite.ListPasskeysRequest) (*ermite.ListPasskeysResponse, error) { return twoPasskeys(), nil }}
47 res := runCLI(t, envFor(f.serve(t)), "", "web", "passkey", "list", "--json").ok(t)
48 assert.Empty(t, res.stderr)
49 assert.JSONEq(t, `[
50 {"id":"AQID","name":"Pixel 9","created_at":"2026-10-01T08:00:00Z","last_used_at":"2026-10-09T18:30:00Z","synced":true},
51 {"id":"BAUG","name":"yubikey","created_at":"2026-10-02T09:15:00Z","last_used_at":null,"synced":false}
52 ]`, res.stdout)
53}
54
55func TestWebPasskeyListCleansServerText(t *testing.T) {
56 f := &fakeAPI{listPasskeys: func(*ermite.ListPasskeysRequest) (*ermite.ListPasskeysResponse, error) {
57 return &ermite.ListPasskeysResponse{Passkeys: []*ermite.Passkey{{Id: "AQ", Name: "evil\x1b[2J name", CreatedAt: ts("2026-10-01T08:00:00Z")}}}, nil
58 }}
59 res := runCLI(t, envFor(f.serve(t)), "", "web", "passkey", "list").ok(t)
60 assert.NotContains(t, res.stdout, "\x1b")
61}
62
63func TestWebPasskeyRemove(t *testing.T) {
64 f := &fakeAPI{removePasskey: func(*ermite.RemovePasskeyRequest) (*ermite.RemovePasskeyResponse, error) {
65 return &ermite.RemovePasskeyResponse{}, nil
66 }}
67 res := runCLI(t, envFor(f.serve(t)), "", "web", "passkey", "remove", "AQID").ok(t)
68 assert.Equal(t, "removed AQID\n", res.stdout)
69 assert.Contains(t, res.stderr, "every browser is signed out")
70
71 calls, reqs := f.snapshot()
72 assert.Equal(t, []string{"RemovePasskey"}, calls)
73 assert.Equal(t, "AQID", reqs[0].(*ermite.RemovePasskeyRequest).GetId())
74}
75
76func TestWebPasskeyRemoveErrors(t *testing.T) {
77 for name, tc := range map[string]struct {
78 code connect.Code
79 msg string
80 want string
81 }{
82 "unknown id": {connect.CodeNotFound, "not found", "not found"},
83 "not the owner": {connect.CodePermissionDenied, "", "permission denied"},
84 "unavailable": {connect.CodeFailedPrecondition, "passkeys are not available on this server", "passkeys are not available on this server"},
85 "bad id": {connect.CodeInvalidArgument, "id is not a base64url credential id", "invalid request"},
86 } {
87 t.Run(name, func(t *testing.T) {
88 f := &fakeAPI{removePasskey: func(*ermite.RemovePasskeyRequest) (*ermite.RemovePasskeyResponse, error) {
89 return nil, connect.NewError(tc.code, errors.New(tc.msg))
90 }}
91 res := runCLI(t, envFor(f.serve(t)), "", "web", "passkey", "remove", "AQID")
92 require.Error(t, res.err)
93 assert.Contains(t, res.err.Error(), tc.want)
94 assert.Empty(t, res.stdout, "nothing is reported as removed")
95 assert.NotContains(t, res.stderr, "signed out")
96 })
97 }
98}
99
100func TestWebPasskeyListUnavailable(t *testing.T) {
101 f := &fakeAPI{listPasskeys: func(*ermite.ListPasskeysRequest) (*ermite.ListPasskeysResponse, error) {
102 return nil, connect.NewError(connect.CodeFailedPrecondition, errors.New("passkeys are not available on this server"))
103 }}
104 res := runCLI(t, envFor(f.serve(t)), "", "web", "passkey", "list")
105 require.Error(t, res.err)
106 assert.Contains(t, res.err.Error(), "passkeys are not available on this server")
107}
108
109func TestWebPasskeyArgs(t *testing.T) {
110 f := &fakeAPI{}
111 url := f.serve(t)
112 for _, args := range [][]string{
113 {"web", "passkey", "remove"},
114 {"web", "passkey", "remove", "a", "b"},
115 {"web", "passkey", "list", "extra"},
116 } {
117 res := runCLI(t, envFor(url), "", args...)
118 require.Error(t, res.err, args)
119 }
120 calls, _ := f.snapshot()
121 assert.Empty(t, calls, "nothing reached the server")
122}
123
124// The ids complete from the server's list.
125func TestWebPasskeyRemoveCompletesIDs(t *testing.T) {
126 f := &fakeAPI{listPasskeys: func(*ermite.ListPasskeysRequest) (*ermite.ListPasskeysResponse, error) { return twoPasskeys(), nil }}
127 res := runCLI(t, envFor(f.serve(t)), "", "__complete", "web", "passkey", "remove", "").ok(t)
128 assert.Contains(t, res.stdout, "AQID\tPixel 9")
129 assert.Contains(t, res.stdout, "BAUG\tyubikey")
130
131 res = runCLI(t, envFor(f.serve(t)), "", "__complete", "web", "passkey", "remove", "B").ok(t)
132 assert.NotContains(t, res.stdout, "AQID")
133 assert.Contains(t, res.stdout, "BAUG\tyubikey")
134}