package repoident import ( "encoding/json" "errors" "strings" "testing" "github.com/bluesky-social/indigo/atproto/identity" ) func knotService(url string) map[string]identity.ServiceEndpoint { return map[string]identity.ServiceEndpoint{KnotServiceID: {Type: KnotServiceType, URL: url}} } func identWith(services map[string]identity.ServiceEndpoint) *identity.Identity { return &identity.Identity{Services: services} } func mustParse(t *testing.T, raw string) KnotURL { t.Helper() u, err := ParseKnotURL(raw, AllowHTTP) if err != nil { t.Fatalf("ParseKnotURL(%q): %v", raw, err) } return u } func TestKnotURL_ParsedAsBaseAndAsServiceEndpoint(t *testing.T) { const canonical = "https://knot.oyster.cafe" const rejected = "" cases := map[string]struct{ wantAsBase, wantAsEndpoint string }{ canonical: {canonical, canonical}, canonical + "/": {canonical, canonical}, "HTTPS://Knot.Oyster.Cafe/": {canonical, canonical}, "https://KNOT.OYSTER.CAFE:443": {canonical, canonical}, "https://knot.oyster.cafe:": {canonical, canonical}, "https://knot.oyster.cafe:80": {canonical + ":80", canonical + ":80"}, "https://[2001:DB8::1]:443": {"https://[2001:db8::1]", "https://[2001:db8::1]"}, "https://[2001:db8::1]:8443": {"https://[2001:db8::1]:8443", "https://[2001:db8::1]:8443"}, "https://[fe80::1%25eth0]": {"https://[fe80::1%25eth0]", "https://[fe80::1%25eth0]"}, "https://☃.oyster.cafe": {"https://%E2%98%83.oyster.cafe", "https://%E2%98%83.oyster.cafe"}, canonical + "/repo/m5326fp3qemiriiqy": {rejected, canonical}, canonical + "/base": {rejected, canonical}, canonical + "?utm=knot": {rejected, rejected}, canonical + "#pulls": {rejected, rejected}, "https://nel@knot.oyster.cafe": {rejected, rejected}, "https://nel:hunter2@knot.oyster.cafe": {rejected, rejected}, "ftp://knot.oyster.cafe": {rejected, rejected}, "http://knot.oyster.cafe": {rejected, rejected}, "knot.oyster.cafe": {rejected, rejected}, "https://": {rejected, rejected}, "https://:443": {rejected, rejected}, "not a url at all": {rejected, rejected}, "": {rejected, rejected}, } for raw, want := range cases { t.Run(raw, func(t *testing.T) { check := func(door string, got KnotURL, err error, want string) { switch { case want == rejected: if err == nil { t.Errorf("%s accepted %q as %q, want an error", door, raw, got) } case err != nil: t.Errorf("%s(%q): %v", door, raw, err) case got.String() != want: t.Errorf("%s(%q) = %q, want %q", door, raw, got, want) case got != mustParse(t, want): t.Errorf("%s(%q) doesn't compare equal to its canonical spelling %q", door, raw, want) } } base, baseErr := ParseKnotURL(raw, RequireHTTPS) check("ParseKnotURL", base, baseErr, want.wantAsBase) endpoint, endpointErr := KnotURLFromIdentity(identWith(knotService(raw)), RequireHTTPS) check("KnotURLFromIdentity", endpoint, endpointErr, want.wantAsEndpoint) }) } } func TestKnotURLFromIdentity_PicksTheKnotService(t *testing.T) { const legacyURL = "https://knot.oyster.cafe" const tangledURL = "https://nel.pet/repo/fcicrjbr6oh3" cases := map[string]struct{ tangledType, want string }{ "legacy atproto_pds is the fallback": {"", legacyURL}, "tangled_knot wins over legacy": {KnotServiceType, "https://nel.pet"}, "tangled_knot with the wrong type is ignored": {"AtprotoLabeler", legacyURL}, } for name, tc := range cases { t.Run(name, func(t *testing.T) { services := map[string]identity.ServiceEndpoint{ LegacyKnotServiceID: {Type: LegacyKnotServiceType, URL: legacyURL}, } if tc.tangledType != "" { services[KnotServiceID] = identity.ServiceEndpoint{Type: tc.tangledType, URL: tangledURL} } u, err := KnotURLFromIdentity(identWith(services), RequireHTTPS) if err != nil { t.Fatalf("KnotURLFromIdentity: %v", err) } if u.String() != tc.want { t.Errorf("KnotURLFromIdentity = %q, want %q", u, tc.want) } }) } } func TestKnotURLFromIdentity_ErrNoKnotService(t *testing.T) { cases := map[string]*identity.Identity{ "nothing declared": identWith(nil), "another service only": identWith(map[string]identity.ServiceEndpoint{ "atproto_labeler": {Type: "AtprotoLabeler", URL: "https://nel.pet"}, }), "tangled_knot with an empty url": identWith(knotService("")), "legacy service with the wrong type": identWith(map[string]identity.ServiceEndpoint{ LegacyKnotServiceID: {Type: "AtprotoLabeler", URL: "https://knot.oyster.cafe"}, }), } for name, ident := range cases { t.Run(name, func(t *testing.T) { if _, err := KnotURLFromIdentity(ident, RequireHTTPS); !errors.Is(err, ErrNoKnotService) { t.Errorf("error = %v, want ErrNoKnotService", err) } }) } if _, err := KnotURLFromIdentity(nil, RequireHTTPS); !errors.Is(err, ErrNilIdentity) { t.Errorf("nil identity error = %v, want ErrNilIdentity", err) } } func TestKnotURL_ErrorQuotesTheDeclaredEndpoint(t *testing.T) { const declared = "https://knot.oyster.cafe/repo/limpet?utm=knot" _, err := KnotURLFromIdentity(identWith(knotService(declared)), RequireHTTPS) if err == nil { t.Fatal("KnotURLFromIdentity accepted a query") } if !strings.Contains(err.Error(), declared) { t.Errorf("error %q doesn't quote the declared endpoint %q", err, declared) } } func TestSchemePolicy_OnlyAllowHTTPPermitsPlaintext(t *testing.T) { if RequireHTTPS != 0 || SchemeFor(true) != AllowHTTP || SchemeFor(false) != RequireHTTPS { t.Fatalf("RequireHTTPS=%d SchemeFor(true)=%d: the zero value must stay RequireHTTPS", RequireHTTPS, SchemeFor(true)) } if u := mustParse(t, "http://knot.oyster.cafe:80"); u.String() != "http://knot.oyster.cafe" { t.Errorf("AllowHTTP parse = %q, want http://knot.oyster.cafe", u) } for _, policy := range []SchemePolicy{RequireHTTPS, SchemePolicy(42), SchemePolicy(-1)} { if _, err := ParseKnotURL("http://knot.oyster.cafe", policy); err == nil { t.Errorf("policy %d permitted http", policy) } } } func TestKnotURL_JoinPathKeepsTheDidColons(t *testing.T) { const want = "http://localhost:5555/did:plc:limpet" if got := mustParse(t, "http://localhost:5555").JoinPath("did:plc:limpet"); got != want { t.Errorf("JoinPath = %q, want %q", got, want) } } func TestKnotURL_ZeroValueIsInert(t *testing.T) { var zero KnotURL if !zero.IsZero() || zero.String() != "" || zero.Host() != "" { t.Errorf("zero KnotURL isn't inert: IsZero=%v String=%q Host=%q", zero.IsZero(), zero.String(), zero.Host()) } if _, err := zero.MarshalText(); !errors.Is(err, ErrZeroKnotURL) { t.Errorf("zero KnotURL MarshalText error = %v, want ErrZeroKnotURL", err) } } func TestKnotURL_JSONCanonicalizesAndValidates(t *testing.T) { var decoded struct { Knot KnotURL `json:"knot"` } if err := json.Unmarshal([]byte(`{"knot":"HTTP://Localhost:80/"}`), &decoded); err != nil { t.Fatalf("Unmarshal: %v", err) } if decoded.Knot.Host() != "localhost" { t.Errorf("decoded host = %q, want localhost", decoded.Knot.Host()) } out, err := json.Marshal(decoded) if err != nil { t.Fatalf("Marshal: %v", err) } if string(out) != `{"knot":"http://localhost"}` { t.Errorf("re-encoded = %s, want {\"knot\":\"http://localhost\"}", out) } if err := json.Unmarshal([]byte(`{"knot":"https://knot.oyster.cafe/repo/limpet"}`), &decoded); err == nil { t.Error("Unmarshal accepted a URL with a path") } }