From 900fce55bc3cea00e8d6e8769c13ce0712e70117 Mon Sep 17 00:00:00 2001 From: stevenvegt Date: Fri, 6 Sep 2024 15:55:19 +0200 Subject: [PATCH] Add start of test suite generation --- cmd/main.go | 25 ++++- cmd/testsuite.go | 194 +++++++++++++++++++++++++++++++++++++ logentry.go | 4 +- logparams.go | 1 - testdata/manifest.json | 82 ++++++++++++++++ testdata/tc001-expect.json | 1 + testdata/tu001-expect.json | 2 + testdata/tu001-input.json | 1 + testdata/tv002-input.json | 1 + testsuite_test.go | 153 +++++++++++++++++++++++++++++ trustdidweb.go | 1 + 11 files changed, 461 insertions(+), 4 deletions(-) create mode 100644 cmd/testsuite.go create mode 100644 testdata/manifest.json create mode 100644 testdata/tc001-expect.json create mode 100644 testdata/tu001-expect.json create mode 100644 testdata/tu001-input.json create mode 100644 testdata/tv002-input.json create mode 100644 testsuite_test.go diff --git a/cmd/main.go b/cmd/main.go index 28c4719..61305ba 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -1,17 +1,40 @@ package main import ( - "encoding/json" + "flag" "fmt" "log/slog" "os" "reflect" + "github.com/go-json-experiment/json" "github.com/go-json-experiment/json/jsontext" tdw "github.com/nuts-foundation/trustdidweb-go" ) func main() { + genSet := flag.NewFlagSet("generate", flag.ExitOnError) + genTests := genSet.Bool("tests", false, "generate tests") + + if len(os.Args) < 2 { + fmt.Println("provide one of following subcommands: generate or example") + os.Exit(1) + } + switch os.Args[1] { + case genSet.Name(): + genSet.Parse(os.Args[2:]) + if *genTests { + GenerateTests() + } + case "example": + example() + default: + fmt.Println("provide a subcommand: generate or example") + os.Exit(1) + } +} + +func example() { // Set the log level to debug and writer to stdout logHandler := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}) slog.SetDefault(slog.New(logHandler)) diff --git a/cmd/testsuite.go b/cmd/testsuite.go new file mode 100644 index 0000000..ea4d535 --- /dev/null +++ b/cmd/testsuite.go @@ -0,0 +1,194 @@ +package main + +import ( + "crypto/ed25519" + "fmt" + "log" + "os" + "time" + + "github.com/go-json-experiment/json" + "github.com/go-json-experiment/json/jsontext" + "github.com/lestrrat-go/jwx/jwk" + tdw "github.com/nuts-foundation/trustdidweb-go" +) + +type TestEntry struct { + Id string `json:"id"` + Type []string `json:"type"` + Purpose string `json:"purpose"` + Input string `json:"input,omitempty"` + Expect string `json:"expect,omitempty"` + SigningKey jwk.Key `json:"signingKey,omitempty"` + Params tdw.LogParams `json:"params,omitempty"` + DIDDocument tdw.DIDDocument `json:"didDocument,omitempty"` + Options TestEntryOptions `json:"options,omitempty"` +} + +type TestEntryOptions struct { + SigningTime time.Time `json:"signingTime,format:RFC3339"` +} + +const CreationTest = "CreationTest" +const UpdateTest = "UpdateTest" +const VerificationTest = "VerificationTest" +const PositiveEvaluationTest = "PositiveEvaluationTest" +const NegativeEvaluationTest = "NegativeEvaluationTest" + +type genEntryFunc func() (entry TestEntry, input tdw.DIDLog, expect tdw.DIDLog, err error) + +func GenerateTests() { + entries := []TestEntry{} + + for _, gen := range []genEntryFunc{genTC001, genTU001, genTV001, genTV002} { + entry, didLogInput, didLogExpect, err := gen() + if err != nil { + log.Fatal(err) + } + + if didLogExpect != nil { + // base the signing time on the actual time of the last entry + entry.Options.SigningTime = didLogExpect[len(didLogExpect)-1].VersionTime + } + + if didLogInput != nil { + inputRaw, err := didLogInput.MarshalText() + if err != nil { + log.Fatal(err) + } + + if err := os.WriteFile(entry.Input, inputRaw, 0644); err != nil { + log.Fatal(err) + } + } + if didLogExpect != nil { + expectedRaw, err := didLogExpect.MarshalText() + if err != nil { + log.Fatal(err) + } + + if err := os.WriteFile(entry.Expect, expectedRaw, 0644); err != nil { + log.Fatal(err) + } + } + + entries = append(entries, entry) + } + + entriesJson, err := json.Marshal(entries, jsontext.WithIndent(" ")) + if err != nil { + log.Fatal(err) + } + + if err := os.WriteFile("testdata/manifest.json", entriesJson, 0644); err != nil { + log.Fatal(err) + } +} + +func genTC001() (entry TestEntry, input tdw.DIDLog, expect tdw.DIDLog, err error) { + + // Create a new document + entry = TestEntry{ + Id: "tc001", + Type: []string{CreationTest, PositiveEvaluationTest}, + Purpose: "Create a new log", + Expect: "testdata/tc001-expect.json", + Options: TestEntryOptions{}, + } + + // Create a new signer + signer, err := tdw.NewSigner(tdw.CRYPTO_SUITE_EDDSA_JCS_2022) + if err != nil { + log.Fatal(err) + } + signingKey, err := jwk.New(*signer.(*ed25519.PrivateKey)) + if err != nil { + log.Fatal(err) + } + entry.SigningKey = signingKey + + doc, err := tdw.NewMinimalDIDDocument("did:tdw:{SCID}:example.com") + if err != nil { + log.Fatal(err) + } + entry.DIDDocument = doc + + expect, err = tdw.Create(doc, signer) + if err != nil { + log.Fatal(err) + } + + return +} + +func genTU001() (entry TestEntry, input tdw.DIDLog, expect tdw.DIDLog, err error) { + + firstEntry, _, input, err := genTC001() + if err != nil { + log.Fatal(err) + } + + // Create a new document + entry = TestEntry{ + Id: "tu001", + Type: []string{UpdateTest, PositiveEvaluationTest}, + Purpose: "Update a log with a service", + Input: "testdata/tu001-input.json", + Expect: "testdata/tu001-expect.json", + Options: TestEntryOptions{}, + } + jwkKey := firstEntry.SigningKey + entry.SigningKey = jwkKey + + signingKey := ed25519.PrivateKey{} + if err := jwkKey.Raw(&signingKey); err != nil { + log.Fatal(err) + } + + doc, err := input.Document() + if err != nil { + log.Fatal(err) + } + + doc["service"] = []map[string]interface{}{{ + "id": fmt.Sprintf("did:tdw:%s:example.com#service-1", input[0].Params.Scid), + "type": "ExampleService", + "serviceEndpoint": "https://example.com/service/1", + }} + + entry.DIDDocument = doc + + expect, err = input.Update(tdw.LogParams{}, doc, signingKey) + if err != nil { + log.Fatal(err) + } + + return +} + +func genTV001() (entry TestEntry, input tdw.DIDLog, expect tdw.DIDLog, err error) { + entry = TestEntry{ + Id: "tv001", + Type: []string{VerificationTest, PositiveEvaluationTest}, + Purpose: "Verify a log", + Input: "testdata/tc001-expect.json", + } + return +} + +func genTV002() (entry TestEntry, input tdw.DIDLog, expect tdw.DIDLog, err error) { + _, _, input, err = genTC001() + if err != nil { + log.Fatal(err) + } + + input[0].Proof[0].ProofValue = "invalid" + + entry = TestEntry{ + Id: "tv002", + Type: []string{VerificationTest, NegativeEvaluationTest}, + Purpose: "Verify a log with an invalid signature", + Input: "testdata/tv002-input.json", + } + return +} diff --git a/logentry.go b/logentry.go index 5abed76..0585654 100644 --- a/logentry.go +++ b/logentry.go @@ -11,7 +11,7 @@ import ( type LogEntry struct { VersionId versionId `json:"versionId"` - VersionTime time.Time `json:"versionTime"` + VersionTime time.Time `json:"versionTime,format:RFC3339"` Params LogParams `json:"params"` DocState docState `json:"docState,omitempty"` Proof []Proof `json:"proof,omitempty"` @@ -82,7 +82,7 @@ func (l *LogEntry) UnmarshalJSONL(b []byte) error { // MarshalJSONL returns the JSON-line representation of the log entry func (l LogEntry) MarshalJSONL() ([]byte, error) { - line := []interface{}{l.VersionId, l.VersionTime, l.Params, l.DocState} + line := []interface{}{l.VersionId, l.VersionTime.Format(time.RFC3339), l.Params, l.DocState} if len(l.Proof) > 0 { line = append(line, l.Proof) diff --git a/logparams.go b/logparams.go index 4585a4b..d9ed2de 100644 --- a/logparams.go +++ b/logparams.go @@ -313,5 +313,4 @@ func (p LogParams) Apply(newParams LogParams) (LogParams, error) { return LogParams{}, fmt.Errorf("invalid log parameters: %w", err) } return res, nil - } diff --git a/testdata/manifest.json b/testdata/manifest.json new file mode 100644 index 0000000..deeea73 --- /dev/null +++ b/testdata/manifest.json @@ -0,0 +1,82 @@ +[ + { + "id": "tc001", + "type": [ + "CreationTest", + "PositiveEvaluationTest" + ], + "purpose": "Create a new log", + "expect": "testdata/tc001-expect.json", + "signingKey": { + "crv": "Ed25519", + "d": "J3l2VTBrN9GckzX8wt0p_FjJ8TDFdKWWNFM-MuHZWVE", + "kty": "OKP", + "x": "Bm-ZiwCZgDgv8K7xf3eGR77Z3vqItWhd0Np5PftRYvM" + }, + "didDocument": { + "@context": [ + "https://www.w3.org/ns/did/v1" + ], + "id": "did:tdw:{SCID}:example.com" + }, + "options": { + "signingTime": "2024-09-06T15:53:15+02:00" + } + }, + { + "id": "tu001", + "type": [ + "UpdateTest", + "PositiveEvaluationTest" + ], + "purpose": "Update a log with a service", + "input": "testdata/tu001-input.json", + "expect": "testdata/tu001-expect.json", + "signingKey": { + "crv": "Ed25519", + "d": "m4EusSPab7ZcfYbZcQbeoLzvZhS4tBp1sCz08qW7I_E", + "kty": "OKP", + "x": "ZtnR3mfJ6_xNrYpwgOX-vfJ_hWIAQvnV1AqDhKJnP24" + }, + "didDocument": { + "@context": [ + "https://www.w3.org/ns/did/v1" + ], + "id": "did:tdw:QmdhbxVLQbjdb2UatyP19k6YsYLmUcgXhoZY2741vodGdG:example.com", + "service": [ + { + "id": "did:tdw:QmdhbxVLQbjdb2UatyP19k6YsYLmUcgXhoZY2741vodGdG:example.com#service-1", + "type": "ExampleService", + "serviceEndpoint": "https://example.com/service/1" + } + ] + }, + "options": { + "signingTime": "2024-09-06T15:53:15+02:00" + } + }, + { + "id": "tv001", + "type": [ + "VerificationTest", + "PositiveEvaluationTest" + ], + "purpose": "Verify a log", + "input": "testdata/tc001-expect.json", + "options": { + "signingTime": "0001-01-01T00:00:00Z" + } + }, + { + "id": "tv002", + "type": [ + "VerificationTest", + "NegativeEvaluationTest" + ], + "purpose": "Verify a log with an invalid signature", + "input": "testdata/tv002-input.json", + "options": { + "signingTime": "0001-01-01T00:00:00Z" + } + } +] \ No newline at end of file diff --git a/testdata/tc001-expect.json b/testdata/tc001-expect.json new file mode 100644 index 0000000..4babb98 --- /dev/null +++ b/testdata/tc001-expect.json @@ -0,0 +1 @@ +["1-Qmbm62bMYQLwi3HZ1xe1pjq9fFPEC3jFVxYUwnrnyzqFZ6","2024-09-06T15:53:15+02:00",{"method":"did:tdw:0.3","scid":"QmNtqEgkGSXKZ38ofL6tJafNur83qKc7bHTU8weHjM9Mpn","updateKeys":["z6MketPC32eF8aZtP5WQcQAexaubxtYoBWshNX2TYrc7yfbg"]},{"value":{"@context":["https://www.w3.org/ns/did/v1"],"id":"did:tdw:QmNtqEgkGSXKZ38ofL6tJafNur83qKc7bHTU8weHjM9Mpn:example.com"}},[{"challenge":"1-Qmbm62bMYQLwi3HZ1xe1pjq9fFPEC3jFVxYUwnrnyzqFZ6","created":"2024-09-06T15:53:15+02:00","cryptosuite":"eddsa-jcs-2022","proofPurpose":"authentication","proofValue":"z5e5L8UZnS88qNfwP9AuBRZjr1idxfWkQKgZvgqAXMorjgJmCS1ZnHbbcVZ4FbUxaLWZHoRXSNVTRJrv1bssmKeef","type":"DataIntegrityProof","verificationMethod":"did:key:z6MketPC32eF8aZtP5WQcQAexaubxtYoBWshNX2TYrc7yfbg#z6MketPC32eF8aZtP5WQcQAexaubxtYoBWshNX2TYrc7yfbg"}]] \ No newline at end of file diff --git a/testdata/tu001-expect.json b/testdata/tu001-expect.json new file mode 100644 index 0000000..a7127f3 --- /dev/null +++ b/testdata/tu001-expect.json @@ -0,0 +1,2 @@ +["1-QmQrc5CvaJbEYmCx9r61En9UzCpxbm8QeZAP5QCJdnygxd","2024-09-06T15:53:15+02:00",{"method":"did:tdw:0.3","scid":"QmdhbxVLQbjdb2UatyP19k6YsYLmUcgXhoZY2741vodGdG","updateKeys":["z6MkmNkGhsR2jCWXrguBh3nhVDQY4Ssgx12oD8Qfk8m4q4of"]},{"value":{"@context":["https://www.w3.org/ns/did/v1"],"id":"did:tdw:QmdhbxVLQbjdb2UatyP19k6YsYLmUcgXhoZY2741vodGdG:example.com"}},[{"challenge":"1-QmQrc5CvaJbEYmCx9r61En9UzCpxbm8QeZAP5QCJdnygxd","created":"2024-09-06T15:53:15+02:00","cryptosuite":"eddsa-jcs-2022","proofPurpose":"authentication","proofValue":"z5Pbr7fgrGSeZtGjgLPR4hxdw4Qxb9PVGX7tj3YsocsSKarLJ22H9amf6e5V4SfVt39ypDK2zthiztNcuq15cu1vy","type":"DataIntegrityProof","verificationMethod":"did:key:z6MkmNkGhsR2jCWXrguBh3nhVDQY4Ssgx12oD8Qfk8m4q4of#z6MkmNkGhsR2jCWXrguBh3nhVDQY4Ssgx12oD8Qfk8m4q4of"}]] +["2-QmS5PDRU2G6LYHDppgkeQrehyksFBjCM9ug4oej44UXRVr","2024-09-06T15:53:15+02:00",{},{"patch":[{"op":"add","path":"/service","value":[{"id":"did:tdw:QmdhbxVLQbjdb2UatyP19k6YsYLmUcgXhoZY2741vodGdG:example.com#service-1","serviceEndpoint":"https://example.com/service/1","type":"ExampleService"}]}]},[{"challenge":"2-QmS5PDRU2G6LYHDppgkeQrehyksFBjCM9ug4oej44UXRVr","created":"2024-09-06T15:53:15+02:00","cryptosuite":"eddsa-jcs-2022","proofPurpose":"authentication","proofValue":"zboDPuw1gzyUv9Ve7Kr1cmXgxX4MPAer4JJ9nLxhERmDCoT5115Xq1bZwTcD6KcpVP5j7vszdr7ppFPrdqLzApt6","type":"DataIntegrityProof","verificationMethod":"did:key:z6MkmNkGhsR2jCWXrguBh3nhVDQY4Ssgx12oD8Qfk8m4q4of#z6MkmNkGhsR2jCWXrguBh3nhVDQY4Ssgx12oD8Qfk8m4q4of"}]] \ No newline at end of file diff --git a/testdata/tu001-input.json b/testdata/tu001-input.json new file mode 100644 index 0000000..4f88fed --- /dev/null +++ b/testdata/tu001-input.json @@ -0,0 +1 @@ +["1-QmQrc5CvaJbEYmCx9r61En9UzCpxbm8QeZAP5QCJdnygxd","2024-09-06T15:53:15+02:00",{"method":"did:tdw:0.3","scid":"QmdhbxVLQbjdb2UatyP19k6YsYLmUcgXhoZY2741vodGdG","updateKeys":["z6MkmNkGhsR2jCWXrguBh3nhVDQY4Ssgx12oD8Qfk8m4q4of"]},{"value":{"@context":["https://www.w3.org/ns/did/v1"],"id":"did:tdw:QmdhbxVLQbjdb2UatyP19k6YsYLmUcgXhoZY2741vodGdG:example.com"}},[{"challenge":"1-QmQrc5CvaJbEYmCx9r61En9UzCpxbm8QeZAP5QCJdnygxd","created":"2024-09-06T15:53:15+02:00","cryptosuite":"eddsa-jcs-2022","proofPurpose":"authentication","proofValue":"z5Pbr7fgrGSeZtGjgLPR4hxdw4Qxb9PVGX7tj3YsocsSKarLJ22H9amf6e5V4SfVt39ypDK2zthiztNcuq15cu1vy","type":"DataIntegrityProof","verificationMethod":"did:key:z6MkmNkGhsR2jCWXrguBh3nhVDQY4Ssgx12oD8Qfk8m4q4of#z6MkmNkGhsR2jCWXrguBh3nhVDQY4Ssgx12oD8Qfk8m4q4of"}]] \ No newline at end of file diff --git a/testdata/tv002-input.json b/testdata/tv002-input.json new file mode 100644 index 0000000..8d5cccd --- /dev/null +++ b/testdata/tv002-input.json @@ -0,0 +1 @@ +["1-QmRfhbuYqDsewfWL8cQFajYyWpiPj61zBp4kHCCguPaHt9","2024-09-06T15:53:15+02:00",{"method":"did:tdw:0.3","scid":"QmebKNjFvJUHkMfPgty1vjMtkqJ1AeR5xvgiiDS4cqKtHm","updateKeys":["z6Mkh72WZYKL8VUYM79V7JGzod4T2dEj1bLnccQG365rfc9i"]},{"value":{"@context":["https://www.w3.org/ns/did/v1"],"id":"did:tdw:QmebKNjFvJUHkMfPgty1vjMtkqJ1AeR5xvgiiDS4cqKtHm:example.com"}},[{"challenge":"1-QmRfhbuYqDsewfWL8cQFajYyWpiPj61zBp4kHCCguPaHt9","created":"2024-09-06T15:53:15+02:00","cryptosuite":"eddsa-jcs-2022","proofPurpose":"authentication","proofValue":"invalid","type":"DataIntegrityProof","verificationMethod":"did:key:z6Mkh72WZYKL8VUYM79V7JGzod4T2dEj1bLnccQG365rfc9i#z6Mkh72WZYKL8VUYM79V7JGzod4T2dEj1bLnccQG365rfc9i"}]] \ No newline at end of file diff --git a/testsuite_test.go b/testsuite_test.go new file mode 100644 index 0000000..b5724ef --- /dev/null +++ b/testsuite_test.go @@ -0,0 +1,153 @@ +package trustdidweb + +import ( + "crypto/ed25519" + "encoding/json" + "fmt" + "os" + "slices" + "testing" + "time" + + "github.com/lestrrat-go/jwx/jwk" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type testEntry struct { + Id string `json:"id"` + Type []string `json:"type"` + Purpose string `json:"purpose"` + Input string `json:"input,omitempty"` + Expect string `json:"expect"` + SigningKey map[string]interface{} `json:"signingKey,omitempty"` + Params LogParams `json:"params,omitempty"` + DIDDocument DIDDocument `json:"didDocument"` + Options testEntryOptions `json:"options,omitempty"` +} +type testEntryOptions struct { + SigningTime time.Time `json:"signingTime,format:RFC3339"` +} + +func (e testEntry) key(t *testing.T) ed25519.PrivateKey { + t.Helper() + + jwkKey, _ := json.Marshal(e.SigningKey) + key, err := jwk.ParseKey(jwkKey) + if err != nil { + t.Fatalf("failed to get private key: %s", err) + } + privKey := ed25519.PrivateKey{} + if err := key.Raw(&privKey); err != nil { + t.Fatalf("failed to get private key: %s", err) + } + return privKey +} + +func TestSuite(t *testing.T) { + + const CreationTest = "CreationTest" + const VerificationTest = "VerificationTest" + const UpdateTest = "UpdateTest" + const PositiveEvaluationTest = "PositiveEvaluationTest" + const NegativeEvaluationTest = "NegativeEvaluationTest" + + t.Run("TestSuite", func(t *testing.T) { + + manifestFile := "testdata/manifest.json" + + // Load the manifest + manifest, err := os.ReadFile(manifestFile) + if err != nil { + t.Fatalf("failed to read manifest file: %s", err) + } + + // Unmarshal the manifest + var entries []testEntry + if err := json.Unmarshal(manifest, &entries); err != nil { + t.Fatalf("failed to unmarshal manifest file: %s", err) + } + + // Run the TestSuite + for _, entry := range entries { + name := fmt.Sprintf("%s-%s", entry.Id, entry.Purpose) + + t.Run(name, func(t *testing.T) { + + if !entry.Options.SigningTime.IsZero() { + oldTimeFunc := timeFunc + defer func() { + timeFunc = oldTimeFunc + }() + timeFunc = func() time.Time { + return entry.Options.SigningTime + } + } + + var resLog DIDLog + switch entry.Type[0] { + case CreationTest: + privKey := entry.key(t) + resLog, err = Create(entry.DIDDocument, privKey) + if err != nil { + require.NoError(t, err) + } + case UpdateTest: + privKey := entry.key(t) + inputFile, err := os.ReadFile(entry.Input) + if err != nil { + t.Fatalf("failed to read input file: %s", err) + } + inputLog, err := ParseLog(inputFile) + if err != nil { + t.Fatalf("failed to parse input file: %s", err) + } + resLog, err = inputLog.Update(entry.Params, entry.DIDDocument, privKey) + if err != nil { + t.Fatalf("failed to update DIDDocument: %s", err) + } + + // check if the documents match + if len(entry.DIDDocument) > 0 { + actualDoc, err := resLog.Document() + if err != nil { + t.Fatalf("failed to get DIDDocument: %s", err) + } + assert.Equal(t, entry.DIDDocument, actualDoc) + } + case VerificationTest: + inputFile, err := os.ReadFile(entry.Input) + if err != nil { + t.Fatalf("failed to read input file: %s", err) + } + inputLog, err := ParseLog(inputFile) + if err != nil { + t.Fatalf("failed to parse input file: %s", err) + } + err = inputLog.Verify() + if slices.Contains(entry.Type, PositiveEvaluationTest) { + assert.NoError(t, err) + } else { + assert.Error(t, err) + } + default: + t.Skipf("unsupported test type: %s", entry.Type[0]) + } + + // only check if there is an expected result defined + if entry.Expect != "" { + actual, err := resLog.MarshalText() + if err != nil { + t.Fatalf("failed to marshal DIDDocument") + } + expected, err := os.ReadFile(entry.Expect) + if err != nil { + t.Fatalf("failed to read expected file: %s", err) + } + assert.Equal(t, string(expected), string(actual)) + } + + }) + } + }) +} diff --git a/trustdidweb.go b/trustdidweb.go index be4865d..a3583b2 100644 --- a/trustdidweb.go +++ b/trustdidweb.go @@ -141,6 +141,7 @@ func (log DIDLog) MarshalJSON() ([]byte, error) { return json.Marshal([]LogEntry(log)) } +// MarshalText returns the log in the JSON Lines format func (log DIDLog) MarshalText() ([]byte, error) { buf := new(bytes.Buffer) for i, entry := range log {