Files

357 lines
12 KiB
Go

package did
import (
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
func testServices(publicURL string) map[string]Service {
return map[string]Service{
"atproto_pds": {Type: "AtprotoPersonalDataServer", Endpoint: publicURL},
"atcr_hold": {Type: "AtcrHoldService", Endpoint: publicURL},
}
}
// TestBuildDIDDocument verifies the standard atproto DID document layout for a did:web service.
func TestBuildDIDDocument(t *testing.T) {
publicURL := "https://hold.example.com"
signingKey := generateK256(t)
doc, err := BuildDIDDocument("did:web:hold.example.com", publicURL, signingKey, "atproto", testServices(publicURL))
if err != nil {
t.Fatalf("BuildDIDDocument: %v", err)
}
if doc.ID != "did:web:hold.example.com" {
t.Errorf("ID: got %s want did:web:hold.example.com", doc.ID)
}
expectedContexts := []string{
"https://www.w3.org/ns/did/v1",
"https://w3id.org/security/multikey/v1",
"https://w3id.org/security/suites/secp256k1-2019/v1",
}
if len(doc.Context) != len(expectedContexts) {
t.Errorf("Context length: got %d want %d", len(doc.Context), len(expectedContexts))
}
for i, expected := range expectedContexts {
if doc.Context[i] != expected {
t.Errorf("Context[%d]: got %s want %s", i, doc.Context[i], expected)
}
}
if len(doc.AlsoKnownAs) != 1 || doc.AlsoKnownAs[0] != "at://hold.example.com" {
t.Errorf("AlsoKnownAs: got %v want [at://hold.example.com]", doc.AlsoKnownAs)
}
if len(doc.VerificationMethod) != 1 {
t.Fatalf("VerificationMethod length: got %d want 1", len(doc.VerificationMethod))
}
vm := doc.VerificationMethod[0]
if vm.ID != "did:web:hold.example.com#atproto" {
t.Errorf("VerificationMethod.ID: got %s", vm.ID)
}
if vm.Type != "Multikey" {
t.Errorf("VerificationMethod.Type: got %s want Multikey", vm.Type)
}
if vm.Controller != "did:web:hold.example.com" {
t.Errorf("VerificationMethod.Controller: got %s", vm.Controller)
}
if vm.PublicKeyMultibase == "" {
t.Error("VerificationMethod.PublicKeyMultibase is empty")
}
pub, _ := signingKey.PublicKey()
if vm.PublicKeyMultibase != pub.Multibase() {
t.Errorf("VerificationMethod.PublicKeyMultibase: got %s want %s", vm.PublicKeyMultibase, pub.Multibase())
}
if len(doc.Authentication) != 1 || doc.Authentication[0] != "did:web:hold.example.com#atproto" {
t.Errorf("Authentication: got %v", doc.Authentication)
}
if len(doc.Service) != 2 {
t.Fatalf("Service length: got %d want 2", len(doc.Service))
}
svcByID := map[string]DIDService{}
for _, s := range doc.Service {
svcByID[s.ID] = s
}
pdsService, ok := svcByID["#atproto_pds"]
if !ok {
t.Fatalf("missing #atproto_pds service in %v", svcByID)
}
if pdsService.Type != "AtprotoPersonalDataServer" {
t.Errorf("#atproto_pds Type: got %s", pdsService.Type)
}
if pdsService.ServiceEndpoint != publicURL {
t.Errorf("#atproto_pds Endpoint: got %s want %s", pdsService.ServiceEndpoint, publicURL)
}
holdService, ok := svcByID["#atcr_hold"]
if !ok {
t.Fatalf("missing #atcr_hold service in %v", svcByID)
}
if holdService.Type != "AtcrHoldService" {
t.Errorf("#atcr_hold Type: got %s", holdService.Type)
}
if holdService.ServiceEndpoint != publicURL {
t.Errorf("#atcr_hold Endpoint: got %s want %s", holdService.ServiceEndpoint, publicURL)
}
}
// TestBuildDIDDocument_WithPort confirms non-standard ports flow into AlsoKnownAs.
func TestBuildDIDDocument_WithPort(t *testing.T) {
publicURL := "https://hold.example.com:8443"
signingKey := generateK256(t)
doc, err := BuildDIDDocument("did:web:hold.example.com%3A8443", publicURL, signingKey, "atproto", testServices(publicURL))
if err != nil {
t.Fatalf("BuildDIDDocument: %v", err)
}
if doc.ID != "did:web:hold.example.com%3A8443" {
t.Errorf("ID: got %s", doc.ID)
}
if doc.AlsoKnownAs[0] != "at://hold.example.com:8443" {
t.Errorf("AlsoKnownAs: got %s want at://hold.example.com:8443", doc.AlsoKnownAs[0])
}
}
// TestBuildDIDDocument_StandardPortsStripped verifies port 80/443 are not appended to alsoKnownAs.
func TestBuildDIDDocument_StandardPortsStripped(t *testing.T) {
signingKey := generateK256(t)
cases := []struct {
name string
publicURL string
wantAKA string
}{
{"http port 80", "http://hold.example.com:80", "at://hold.example.com"},
{"https port 443", "https://hold.example.com:443", "at://hold.example.com"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
doc, err := BuildDIDDocument("did:web:hold.example.com", tc.publicURL, signingKey, "atproto", nil)
if err != nil {
t.Fatalf("BuildDIDDocument: %v", err)
}
if doc.AlsoKnownAs[0] != tc.wantAKA {
t.Errorf("AlsoKnownAs: got %s want %s", doc.AlsoKnownAs[0], tc.wantAKA)
}
})
}
}
// TestBuildDIDDocument_InvalidURL confirms malformed URLs surface as errors.
func TestBuildDIDDocument_InvalidURL(t *testing.T) {
signingKey := generateK256(t)
_, err := BuildDIDDocument("did:web:bogus", "ht!tp://invalid url", signingKey, "atproto", nil)
if err == nil {
t.Fatal("expected error for invalid URL, got nil")
}
}
// TestBuildDIDDocument_DefaultVerificationKeyName confirms the empty fragment defaults to "atproto"
// and adds Authentication.
func TestBuildDIDDocument_DefaultVerificationKeyName(t *testing.T) {
signingKey := generateK256(t)
doc, err := BuildDIDDocument("did:web:example.com", "https://example.com", signingKey, "", nil)
if err != nil {
t.Fatalf("BuildDIDDocument: %v", err)
}
if doc.VerificationMethod[0].ID != "did:web:example.com#atproto" {
t.Errorf("VerificationMethod.ID: got %s want did:web:example.com#atproto", doc.VerificationMethod[0].ID)
}
if len(doc.Authentication) != 1 || doc.Authentication[0] != "did:web:example.com#atproto" {
t.Errorf("Authentication: got %v", doc.Authentication)
}
}
// TestBuildDIDDocument_LabelerKey confirms a non-"atproto" verification key (e.g. labeler)
// does not add Authentication, mirroring the bsky labeler pattern.
func TestBuildDIDDocument_LabelerKey(t *testing.T) {
signingKey := generateK256(t)
services := map[string]Service{
"atproto_labeler": {Type: "AtprotoLabeler", Endpoint: "https://labeler.example.com"},
}
doc, err := BuildDIDDocument("did:web:labeler.example.com", "https://labeler.example.com", signingKey, "atproto_label", services)
if err != nil {
t.Fatalf("BuildDIDDocument: %v", err)
}
if doc.VerificationMethod[0].ID != "did:web:labeler.example.com#atproto_label" {
t.Errorf("VerificationMethod.ID: got %s", doc.VerificationMethod[0].ID)
}
if len(doc.Authentication) != 0 {
t.Errorf("Authentication should be empty for non-atproto key, got %v", doc.Authentication)
}
if len(doc.Service) != 1 || doc.Service[0].ID != "#atproto_labeler" {
t.Errorf("Service: got %v", doc.Service)
}
}
// TestBuildDIDDocument_NoServices confirms a DID document can be built without any service entries.
func TestBuildDIDDocument_NoServices(t *testing.T) {
signingKey := generateK256(t)
doc, err := BuildDIDDocument("did:web:example.com", "https://example.com", signingKey, "atproto", nil)
if err != nil {
t.Fatalf("BuildDIDDocument: %v", err)
}
if len(doc.Service) != 0 {
t.Errorf("Service should be empty, got %v", doc.Service)
}
}
// TestMarshalDIDDocument confirms marshaling produces parseable, indented JSON.
func TestMarshalDIDDocument(t *testing.T) {
signingKey := generateK256(t)
doc, err := BuildDIDDocument("did:web:example.com", "https://example.com", signingKey, "atproto", testServices("https://example.com"))
if err != nil {
t.Fatalf("BuildDIDDocument: %v", err)
}
data, err := MarshalDIDDocument(doc)
if err != nil {
t.Fatalf("MarshalDIDDocument: %v", err)
}
if !strings.Contains(string(data), " ") {
t.Error("expected indented JSON output")
}
var parsed DIDDocument
if err := json.Unmarshal(data, &parsed); err != nil {
t.Fatalf("Unmarshal: %v", err)
}
if parsed.ID != doc.ID {
t.Errorf("ID round-trip: got %s want %s", parsed.ID, doc.ID)
}
if len(parsed.Service) != len(doc.Service) {
t.Errorf("Service length round-trip: got %d want %d", len(parsed.Service), len(doc.Service))
}
}
// TestLoadOrCreate_DIDWeb confirms did:web mode returns a deterministic identifier
// without touching disk or any external service.
func TestLoadOrCreate_DIDWeb(t *testing.T) {
cfg := Config{
Method: "web",
PublicURL: "https://hold.example.com",
}
d, err := LoadOrCreate(context.Background(), cfg)
if err != nil {
t.Fatalf("LoadOrCreate: %v", err)
}
if d != "did:web:hold.example.com" {
t.Errorf("DID: got %s want did:web:hold.example.com", d)
}
}
// TestLoadOrCreate_DIDWebDefaultsToWebWhenMethodEmpty confirms an empty method behaves like did:web.
func TestLoadOrCreate_DIDWebDefaultsToWebWhenMethodEmpty(t *testing.T) {
cfg := Config{
PublicURL: "https://example.com:8443",
}
d, err := LoadOrCreate(context.Background(), cfg)
if err != nil {
t.Fatalf("LoadOrCreate: %v", err)
}
if d != "did:web:example.com%3A8443" {
t.Errorf("DID: got %s want did:web:example.com%%3A8443", d)
}
}
// TestLoadOrCreate_PLCRequiresVerificationKeyName confirms missing required PLC fields error early.
func TestLoadOrCreate_PLCRequiresVerificationKeyName(t *testing.T) {
cfg := Config{
Method: "plc",
PublicURL: "https://example.com",
Services: map[string]Service{
"atproto_pds": {Type: "AtprotoPersonalDataServer", Endpoint: "https://example.com"},
},
}
_, err := LoadOrCreate(context.Background(), cfg)
if err == nil {
t.Fatal("expected error when VerificationKeyName is empty")
}
if !strings.Contains(err.Error(), "VerificationKeyName") {
t.Errorf("expected error about VerificationKeyName, got: %v", err)
}
}
// TestLoadOrCreate_PLCRequiresServices confirms PLC mode demands at least one service entry.
func TestLoadOrCreate_PLCRequiresServices(t *testing.T) {
cfg := Config{
Method: "plc",
PublicURL: "https://example.com",
VerificationKeyName: "atproto",
}
_, err := LoadOrCreate(context.Background(), cfg)
if err == nil {
t.Fatal("expected error when Services is empty")
}
if !strings.Contains(err.Error(), "service") {
t.Errorf("expected error about services, got: %v", err)
}
}
// TestLoadOrCreate_PLCRejectsNonPLCAdoption confirms a configured DID must be a did:plc identifier.
func TestLoadOrCreate_PLCRejectsNonPLCAdoption(t *testing.T) {
tmp := t.TempDir()
cfg := Config{
Method: "plc",
PublicURL: "https://example.com",
DBPath: tmp,
SigningKeyPath: filepath.Join(tmp, "signing.key"),
VerificationKeyName: "atproto",
DID: "did:web:example.com",
Services: map[string]Service{
"atproto_pds": {Type: "AtprotoPersonalDataServer", Endpoint: "https://example.com"},
},
}
_, err := LoadOrCreate(context.Background(), cfg)
if err == nil {
t.Fatal("expected error for non-did:plc adoption")
}
if !strings.Contains(err.Error(), "did:plc") {
t.Errorf("expected error about did:plc, got: %v", err)
}
}
// TestLoadOrCreate_PLCAdoptionPersistsDID confirms a configured did:plc is written to did.txt
// even when the PLC directory call fails (the failure is logged, not returned).
func TestLoadOrCreate_PLCAdoptionPersistsDID(t *testing.T) {
tmp := t.TempDir()
cfg := Config{
Method: "plc",
PublicURL: "https://example.com",
DBPath: tmp,
SigningKeyPath: filepath.Join(tmp, "signing.key"),
VerificationKeyName: "atproto",
DID: "did:plc:abcdefghijklmnopqrstuvwx",
PLCDirectoryURL: "http://127.0.0.1:1", // unreachable; EnsureCurrent failure is non-fatal
Services: map[string]Service{
"atproto_pds": {Type: "AtprotoPersonalDataServer", Endpoint: "https://example.com"},
},
}
d, err := LoadOrCreate(context.Background(), cfg)
if err != nil {
t.Fatalf("LoadOrCreate: %v", err)
}
if d != "did:plc:abcdefghijklmnopqrstuvwx" {
t.Errorf("DID: got %s", d)
}
got, err := os.ReadFile(filepath.Join(tmp, "did.txt"))
if err != nil {
t.Fatalf("read did.txt: %v", err)
}
if strings.TrimSpace(string(got)) != "did:plc:abcdefghijklmnopqrstuvwx" {
t.Errorf("did.txt contents: got %q", string(got))
}
}