This repository has no description
1package repoident
2
3import (
4 "encoding/json"
5 "errors"
6 "strings"
7 "testing"
8
9 "github.com/bluesky-social/indigo/atproto/identity"
10)
11
12func knotService(url string) map[string]identity.ServiceEndpoint {
13 return map[string]identity.ServiceEndpoint{KnotServiceID: {Type: KnotServiceType, URL: url}}
14}
15
16func identWith(services map[string]identity.ServiceEndpoint) *identity.Identity {
17 return &identity.Identity{Services: services}
18}
19
20func mustParse(t *testing.T, raw string) KnotURL {
21 t.Helper()
22 u, err := ParseKnotURL(raw, AllowHTTP)
23 if err != nil {
24 t.Fatalf("ParseKnotURL(%q): %v", raw, err)
25 }
26 return u
27}
28
29func TestKnotURL_ParsedAsBaseAndAsServiceEndpoint(t *testing.T) {
30 const canonical = "https://knot.oyster.cafe"
31 const rejected = ""
32 cases := map[string]struct{ wantAsBase, wantAsEndpoint string }{
33 canonical: {canonical, canonical},
34 canonical + "/": {canonical, canonical},
35 "HTTPS://Knot.Oyster.Cafe/": {canonical, canonical},
36 "https://KNOT.OYSTER.CAFE:443": {canonical, canonical},
37 "https://knot.oyster.cafe:": {canonical, canonical},
38 "https://knot.oyster.cafe:80": {canonical + ":80", canonical + ":80"},
39 "https://[2001:DB8::1]:443": {"https://[2001:db8::1]", "https://[2001:db8::1]"},
40 "https://[2001:db8::1]:8443": {"https://[2001:db8::1]:8443", "https://[2001:db8::1]:8443"},
41 "https://[fe80::1%25eth0]": {"https://[fe80::1%25eth0]", "https://[fe80::1%25eth0]"},
42 "https://☃.oyster.cafe": {"https://%E2%98%83.oyster.cafe", "https://%E2%98%83.oyster.cafe"},
43 canonical + "/repo/m5326fp3qemiriiqy": {rejected, canonical},
44 canonical + "/base": {rejected, canonical},
45 canonical + "?utm=knot": {rejected, rejected},
46 canonical + "#pulls": {rejected, rejected},
47 "https://nel@knot.oyster.cafe": {rejected, rejected},
48 "https://nel:hunter2@knot.oyster.cafe": {rejected, rejected},
49 "ftp://knot.oyster.cafe": {rejected, rejected},
50 "http://knot.oyster.cafe": {rejected, rejected},
51 "knot.oyster.cafe": {rejected, rejected},
52 "https://": {rejected, rejected},
53 "https://:443": {rejected, rejected},
54 "not a url at all": {rejected, rejected},
55 "": {rejected, rejected},
56 }
57 for raw, want := range cases {
58 t.Run(raw, func(t *testing.T) {
59 check := func(door string, got KnotURL, err error, want string) {
60 switch {
61 case want == rejected:
62 if err == nil {
63 t.Errorf("%s accepted %q as %q, want an error", door, raw, got)
64 }
65 case err != nil:
66 t.Errorf("%s(%q): %v", door, raw, err)
67 case got.String() != want:
68 t.Errorf("%s(%q) = %q, want %q", door, raw, got, want)
69 case got != mustParse(t, want):
70 t.Errorf("%s(%q) doesn't compare equal to its canonical spelling %q", door, raw, want)
71 }
72 }
73 base, baseErr := ParseKnotURL(raw, RequireHTTPS)
74 check("ParseKnotURL", base, baseErr, want.wantAsBase)
75 endpoint, endpointErr := KnotURLFromIdentity(identWith(knotService(raw)), RequireHTTPS)
76 check("KnotURLFromIdentity", endpoint, endpointErr, want.wantAsEndpoint)
77 })
78 }
79}
80
81func TestKnotURLFromIdentity_PicksTheKnotService(t *testing.T) {
82 const legacyURL = "https://knot.oyster.cafe"
83 const tangledURL = "https://nel.pet/repo/fcicrjbr6oh3"
84 cases := map[string]struct{ tangledType, want string }{
85 "legacy atproto_pds is the fallback": {"", legacyURL},
86 "tangled_knot wins over legacy": {KnotServiceType, "https://nel.pet"},
87 "tangled_knot with the wrong type is ignored": {"AtprotoLabeler", legacyURL},
88 }
89 for name, tc := range cases {
90 t.Run(name, func(t *testing.T) {
91 services := map[string]identity.ServiceEndpoint{
92 LegacyKnotServiceID: {Type: LegacyKnotServiceType, URL: legacyURL},
93 }
94 if tc.tangledType != "" {
95 services[KnotServiceID] = identity.ServiceEndpoint{Type: tc.tangledType, URL: tangledURL}
96 }
97 u, err := KnotURLFromIdentity(identWith(services), RequireHTTPS)
98 if err != nil {
99 t.Fatalf("KnotURLFromIdentity: %v", err)
100 }
101 if u.String() != tc.want {
102 t.Errorf("KnotURLFromIdentity = %q, want %q", u, tc.want)
103 }
104 })
105 }
106}
107
108func TestKnotURLFromIdentity_ErrNoKnotService(t *testing.T) {
109 cases := map[string]*identity.Identity{
110 "nothing declared": identWith(nil),
111 "another service only": identWith(map[string]identity.ServiceEndpoint{
112 "atproto_labeler": {Type: "AtprotoLabeler", URL: "https://nel.pet"},
113 }),
114 "tangled_knot with an empty url": identWith(knotService("")),
115 "legacy service with the wrong type": identWith(map[string]identity.ServiceEndpoint{
116 LegacyKnotServiceID: {Type: "AtprotoLabeler", URL: "https://knot.oyster.cafe"},
117 }),
118 }
119 for name, ident := range cases {
120 t.Run(name, func(t *testing.T) {
121 if _, err := KnotURLFromIdentity(ident, RequireHTTPS); !errors.Is(err, ErrNoKnotService) {
122 t.Errorf("error = %v, want ErrNoKnotService", err)
123 }
124 })
125 }
126 if _, err := KnotURLFromIdentity(nil, RequireHTTPS); !errors.Is(err, ErrNilIdentity) {
127 t.Errorf("nil identity error = %v, want ErrNilIdentity", err)
128 }
129}
130
131func TestKnotURL_ErrorQuotesTheDeclaredEndpoint(t *testing.T) {
132 const declared = "https://knot.oyster.cafe/repo/limpet?utm=knot"
133 _, err := KnotURLFromIdentity(identWith(knotService(declared)), RequireHTTPS)
134 if err == nil {
135 t.Fatal("KnotURLFromIdentity accepted a query")
136 }
137 if !strings.Contains(err.Error(), declared) {
138 t.Errorf("error %q doesn't quote the declared endpoint %q", err, declared)
139 }
140}
141
142func TestSchemePolicy_OnlyAllowHTTPPermitsPlaintext(t *testing.T) {
143 if RequireHTTPS != 0 || SchemeFor(true) != AllowHTTP || SchemeFor(false) != RequireHTTPS {
144 t.Fatalf("RequireHTTPS=%d SchemeFor(true)=%d: the zero value must stay RequireHTTPS", RequireHTTPS, SchemeFor(true))
145 }
146 if u := mustParse(t, "http://knot.oyster.cafe:80"); u.String() != "http://knot.oyster.cafe" {
147 t.Errorf("AllowHTTP parse = %q, want http://knot.oyster.cafe", u)
148 }
149 for _, policy := range []SchemePolicy{RequireHTTPS, SchemePolicy(42), SchemePolicy(-1)} {
150 if _, err := ParseKnotURL("http://knot.oyster.cafe", policy); err == nil {
151 t.Errorf("policy %d permitted http", policy)
152 }
153 }
154}
155
156func TestKnotURL_JoinPathKeepsTheDidColons(t *testing.T) {
157 const want = "http://localhost:5555/did:plc:limpet"
158 if got := mustParse(t, "http://localhost:5555").JoinPath("did:plc:limpet"); got != want {
159 t.Errorf("JoinPath = %q, want %q", got, want)
160 }
161}
162
163func TestKnotURL_ZeroValueIsInert(t *testing.T) {
164 var zero KnotURL
165 if !zero.IsZero() || zero.String() != "" || zero.Host() != "" {
166 t.Errorf("zero KnotURL isn't inert: IsZero=%v String=%q Host=%q", zero.IsZero(), zero.String(), zero.Host())
167 }
168 if _, err := zero.MarshalText(); !errors.Is(err, ErrZeroKnotURL) {
169 t.Errorf("zero KnotURL MarshalText error = %v, want ErrZeroKnotURL", err)
170 }
171}
172
173func TestKnotURL_JSONCanonicalizesAndValidates(t *testing.T) {
174 var decoded struct {
175 Knot KnotURL `json:"knot"`
176 }
177 if err := json.Unmarshal([]byte(`{"knot":"HTTP://Localhost:80/"}`), &decoded); err != nil {
178 t.Fatalf("Unmarshal: %v", err)
179 }
180 if decoded.Knot.Host() != "localhost" {
181 t.Errorf("decoded host = %q, want localhost", decoded.Knot.Host())
182 }
183 out, err := json.Marshal(decoded)
184 if err != nil {
185 t.Fatalf("Marshal: %v", err)
186 }
187 if string(out) != `{"knot":"http://localhost"}` {
188 t.Errorf("re-encoded = %s, want {\"knot\":\"http://localhost\"}", out)
189 }
190 if err := json.Unmarshal([]byte(`{"knot":"https://knot.oyster.cafe/repo/limpet"}`), &decoded); err == nil {
191 t.Error("Unmarshal accepted a URL with a path")
192 }
193}