internal/worker/store: storage layout for worker

Add types and code that will be used to store worker data in a DB.

The DB contains two kinds of data:

- CVERecords, holding CVEs and related information.
- UpdateRecords, metadata that describe the worker's update runs.

The main interface to the storage layer is a Store.

Provide a memory-based implementation of Store for testing.

Change-Id: Ie9e6c88c354a4946383b2f72897a28633ddc98f7
Reviewed-on: https://go-review.googlesource.com/c/vuln/+/366154
Trust: Jonathan Amsterdam <jba@google.com>
Reviewed-by: Julie Qiu <julie@golang.org>
diff --git a/go.mod b/go.mod
index 92f7316..d4194cb 100644
--- a/go.mod
+++ b/go.mod
@@ -18,10 +18,10 @@
 	github.com/sergi/go-diff v1.1.0 // indirect
 	github.com/xanzy/ssh-agent v0.3.0 // indirect
 	golang.org/x/crypto v0.0.0-20210817164053-32db794688a5 // indirect
-	golang.org/x/mod v0.4.1
-	golang.org/x/net v0.0.0-20210326060303-6b1517762897 // indirect
-	golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1 // indirect
-	golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 // indirect
+	golang.org/x/mod v0.4.2
+	golang.org/x/net v0.0.0-20210503060351-7fd8e65b6420 // indirect
+	golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac // indirect
+	golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
 	gopkg.in/warnings.v0 v0.1.2 // indirect
 	gopkg.in/yaml.v2 v2.4.0
 )
diff --git a/go.sum b/go.sum
index ea8f4a0..cd3bec2 100644
--- a/go.sum
+++ b/go.sum
@@ -73,13 +73,14 @@
 golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
 golang.org/x/crypto v0.0.0-20210817164053-32db794688a5 h1:HWj/xjIHfjYU5nVXpTM0s39J9CbLn7Cc5a7IC5rwsMQ=
 golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
-golang.org/x/mod v0.4.1 h1:Kvvh58BN8Y9/lBi7hTekvtMpm07eUZ0ck5pRHpsMWrY=
-golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
+golang.org/x/mod v0.4.2 h1:Gz96sIWK3OalVv/I/qNygP42zyoKp3xptRVCWRFEBvo=
+golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
 golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
 golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
 golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
-golang.org/x/net v0.0.0-20210326060303-6b1517762897 h1:KrsHThm5nFk34YtATK1LsThyGhGbGe1olrte/HInHvs=
 golang.org/x/net v0.0.0-20210326060303-6b1517762897/go.mod h1:uSPa2vr4CLtc/ILN5odXGNXS6mhrKVzTaCXzk9m6W3k=
+golang.org/x/net v0.0.0-20210503060351-7fd8e65b6420 h1:a8jGStKg0XqKDlKqjLrXn0ioF5MH36pT7Z0BRTqLhbk=
+golang.org/x/net v0.0.0-20210503060351-7fd8e65b6420/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
 golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -90,20 +91,24 @@
 golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210324051608-47abb6519492/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210502180810-71e4cd670f79/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1 h1:SrN+KX8Art/Sf4HNj6Zcz06G7VEz+7w9tdXTPOZ7+l4=
 golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac h1:oN6lz7iLW/YC7un8pq+9bOLyXrprv2+DKfkJY+2LJJw=
+golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1 h1:v+OssWQX+hTHEmOBgwxdZxK4zHq3yOs8F9J7mk0PY8E=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
-golang.org/x/text v0.3.3 h1:cokOdA+Jmi5PJGXLlLllQSgYigAEfHXJAERHVMaCc2k=
 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.3.6 h1:aRYxNxv6iGQlyVaZmk6ZgYEDa+Jg18DxebPSrd6bg1M=
+golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
 golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
+golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
diff --git a/internal/worker/store/mem_store.go b/internal/worker/store/mem_store.go
new file mode 100644
index 0000000..d0378e7
--- /dev/null
+++ b/internal/worker/store/mem_store.go
@@ -0,0 +1,125 @@
+// Copyright 2021 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package store
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"math/rand"
+	"sort"
+	"sync"
+	"time"
+)
+
+// MemStore is an in-memory implementation of Store, for testing.
+type MemStore struct {
+	mu            sync.Mutex
+	cveRecords    map[string]*CVERecord
+	updateRecords map[string]*UpdateRecord
+}
+
+// NewMemStore creates a new, empty MemStore.
+func NewMemStore() *MemStore {
+	m := &MemStore{}
+	_ = m.Clear(nil)
+	return m
+}
+
+// Clear removes all data from the MemStore.
+func (ms *MemStore) Clear(context.Context) error {
+	ms.cveRecords = map[string]*CVERecord{}
+	ms.updateRecords = map[string]*UpdateRecord{}
+	return nil
+}
+
+// CVERecords return all the CVERecords of the store.
+func (ms *MemStore) CVERecords() map[string]*CVERecord {
+	return ms.cveRecords
+}
+
+// CreateUpdateRecord implements Store.CreateUpdateRecord.
+func (ms *MemStore) CreateUpdateRecord(ctx context.Context, r *UpdateRecord) error {
+	r.ID = fmt.Sprint(rand.Uint32())
+	if ms.updateRecords[r.ID] != nil {
+		panic("duplicate ID")
+	}
+	r.UpdatedAt = time.Now()
+	return ms.SetUpdateRecord(ctx, r)
+}
+
+// SetUpdateRecord implements Store.SetUpdateRecord.
+func (ms *MemStore) SetUpdateRecord(_ context.Context, r *UpdateRecord) error {
+	if r.ID == "" {
+		return errors.New("SetUpdateRecord: need ID")
+	}
+	c := *r
+	c.UpdatedAt = time.Now()
+	ms.updateRecords[c.ID] = &c
+	return nil
+}
+
+// ListUpdateRecords implements Store.ListUpdateRecords.
+func (ms *MemStore) ListUpdateRecords(context.Context) ([]*UpdateRecord, error) {
+	var urs []*UpdateRecord
+	for _, ur := range ms.updateRecords {
+		urs = append(urs, ur)
+	}
+	sort.Slice(urs, func(i, j int) bool {
+		return urs[i].StartedAt.After(urs[j].StartedAt)
+	})
+	return urs, nil
+}
+
+// RunTransaction implements Store.RunTransaction.
+// A transaction runs with a single lock on the entire DB.
+func (ms *MemStore) RunTransaction(ctx context.Context, f func(context.Context, Transaction) error) error {
+	tx := &memTransaction{ms}
+	ms.mu.Lock()
+	defer ms.mu.Unlock()
+	return f(ctx, tx)
+}
+
+// memTransaction implements Store.Transaction.
+type memTransaction struct {
+	ms *MemStore
+}
+
+// CreateCVERecord implements Transaction.CreateCVERecord.
+func (tx *memTransaction) CreateCVERecord(r *CVERecord) error {
+	if err := r.Validate(); err != nil {
+		return err
+	}
+	tx.ms.cveRecords[r.ID] = r
+	return nil
+}
+
+// SetCVERecord implements Transaction.SetCVERecord.
+func (tx *memTransaction) SetCVERecord(r *CVERecord) error {
+	if err := r.Validate(); err != nil {
+		return err
+	}
+	if tx.ms.cveRecords[r.ID] == nil {
+		return fmt.Errorf("CVERecord with ID %q not found", r.ID)
+	}
+	tx.ms.cveRecords[r.ID] = r
+	return nil
+}
+
+// GetCVERecords implements Transaction.GetCVERecords.
+func (tx *memTransaction) GetCVERecords(startID, endID string) ([]*CVERecord, error) {
+	var crs []*CVERecord
+	for id, r := range tx.ms.cveRecords {
+		if id >= startID && id <= endID {
+			c := *r
+			crs = append(crs, &c)
+		}
+	}
+	// Sort for testing.
+	sort.Slice(crs, func(i, j int) bool {
+		return crs[i].ID < crs[j].ID
+	})
+	return crs, nil
+}
diff --git a/internal/worker/store/mem_store_test.go b/internal/worker/store/mem_store_test.go
new file mode 100644
index 0000000..2779041
--- /dev/null
+++ b/internal/worker/store/mem_store_test.go
@@ -0,0 +1,11 @@
+// Copyright 2021 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package store
+
+import "testing"
+
+func TestMemStore(t *testing.T) {
+	testStore(t, NewMemStore())
+}
diff --git a/internal/worker/store/store.go b/internal/worker/store/store.go
new file mode 100644
index 0000000..776643d
--- /dev/null
+++ b/internal/worker/store/store.go
@@ -0,0 +1,153 @@
+// Copyright 2021 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+// Package store supports permanent data storage for the vuln worker.
+package store
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"time"
+
+	"golang.org/x/vuln/internal/cveschema"
+)
+
+// A CVERecord contains information about a CVE.
+type CVERecord struct {
+	// ID is the CVE ID, which is the same as the filename base. E.g. "CVE-2020-0034".
+	ID string
+	// Path is the path to the CVE file in the repo.
+	Path string
+	// Blobhash is the hash of the CVE's blob in repo, for quick change detection.
+	BlobHash string
+	// CommitHash is the commit of the cvelist repo from which this information came.
+	CommitHash string
+	// CVEState is the value of the metadata.STATE field.
+	CVEState string
+	// TriageState is the state of our triage processing on the CVE.
+	TriageState TriageState
+	// TriageStateReason is an explanation of TriageState.
+	TriageStateReason string
+
+	// IssueReference is a reference to the GitHub issue that was filed.
+	// E.g. golang/vulndb#12345.
+	// Set only after a GitHub issue has been successfully created.
+	IssueReference string
+
+	// IssueCreatedAt is the time when the issue was created.
+	// Set only after a GitHub issue has been successfully created.
+	IssueCreatedAt time.Time
+}
+
+// Validate returns an error if the CVERecord is not valid.
+func (r *CVERecord) Validate() error {
+	if r.ID == "" {
+		return errors.New("need ID")
+	}
+	if r.Path == "" {
+		return errors.New("need Path")
+	}
+	if r.BlobHash == "" {
+		return errors.New("need BlobHash")
+	}
+	if r.CommitHash == "" {
+		return errors.New("need CommitHash")
+	}
+	return r.TriageState.Validate()
+}
+
+// TriageState is the state of our work on the CVE.
+// It is implemented as a string rather than an int so that stored values are
+// immune to renumbering.
+type TriageState string
+
+const (
+	// No action is needed on the CVE (perhaps because it is rejected, reserved or invalid).
+	TriageStateNoActionNeeded TriageState = "NoActionNeeded"
+	// The CVE needs to have an issue created.
+	TriageStateNeedsIssue TriageState = "NeedsIssue"
+	// An issue has been created in the issue tracker.
+	// The IssueReference and IssueCreatedAt fields have more information.
+	TriageStateIssueCreated TriageState = "IssueCreated"
+	// The CVE state was changed after the CVE was created.
+	TriageStateUpdatedSinceIssueCreation TriageState = "UpdatedSinceIssueCreation"
+)
+
+// Validate returns an error if the TriageState is not one of the above values.
+func (s TriageState) Validate() error {
+	if s == TriageStateNoActionNeeded || s == TriageStateNeedsIssue || s == TriageStateIssueCreated || s == TriageStateUpdatedSinceIssueCreation {
+		return nil
+	}
+	return fmt.Errorf("bad TriageState %q", s)
+}
+
+// NewCVERecord creates a CVERecord from a CVE, its path and its blob hash.
+func NewCVERecord(cve *cveschema.CVE, path, blobHash string) *CVERecord {
+	return &CVERecord{
+		ID:       cve.ID,
+		CVEState: cve.State,
+		Path:     path,
+		BlobHash: blobHash,
+	}
+}
+
+// An UpdateRecord describes a single update operation, which reconciles
+// a commit in the CVE list repo with the DB state.
+type UpdateRecord struct {
+	// The ID of this record in the DB. Needed to modify the record.
+	ID string
+	// When the update started and completed. If EndedAt is zero,
+	// the update is in progress (or it crashed).
+	StartedAt, EndedAt time.Time
+	// The repo commit hash that this update is working on.
+	CommitHash string
+	// The total number of CVEs being processed in this update.
+	NumTotal int
+	// The number currently processed. When this equals NumTotal, the
+	// update is done.
+	NumProcessed int
+	// The number of CVEs added to the DB.
+	NumAdded int
+	// The number of CVEs modified.
+	NumModified int
+	// The error that stopped the update.
+	Error string
+	// The last time this record was updated.
+	UpdatedAt time.Time `firestore:",serverTimestamp"`
+}
+
+// A Store is a storage system for the CVE database.
+type Store interface {
+	// CreateUpdateRecord creates a new UpdateRecord. It should be called at the start
+	// of an update. On successful return, the UpdateRecord's ID field will be
+	// set to a new, unique ID.
+	CreateUpdateRecord(context.Context, *UpdateRecord) error
+
+	// SetUpdateRecord modifies the UpdateRecord. Use the same record passed to
+	// CreateUpdateRecord, because it will have the correct ID.
+	SetUpdateRecord(context.Context, *UpdateRecord) error
+
+	// ListUpdateRecords returns all the UpdateRecords in the store, from most to
+	// least recent.
+	ListUpdateRecords(context.Context) ([]*UpdateRecord, error)
+
+	// RunTransaction runs the function in a transaction.
+	RunTransaction(context.Context, func(context.Context, Transaction) error) error
+}
+
+// Transaction supports store operations that run inside a transaction.
+type Transaction interface {
+	// CreateCVERecord creates a new CVERecord. It is an error if one with the same ID
+	// already exists.
+	CreateCVERecord(*CVERecord) error
+
+	// SetCVERecord sets the CVE record in the database. It is
+	// an error if no such record exists.
+	SetCVERecord(r *CVERecord) error
+
+	// GetCVERecords retrieves CVERecords for all CVE IDs between startID and
+	// endID, inclusive.
+	GetCVERecords(startID, endID string) ([]*CVERecord, error)
+}
diff --git a/internal/worker/store/store_test.go b/internal/worker/store/store_test.go
new file mode 100644
index 0000000..8bf93cd
--- /dev/null
+++ b/internal/worker/store/store_test.go
@@ -0,0 +1,170 @@
+// Copyright 2021 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package store
+
+import (
+	"context"
+	"testing"
+	"time"
+
+	"github.com/google/go-cmp/cmp"
+	"github.com/google/go-cmp/cmp/cmpopts"
+	"golang.org/x/vuln/internal/cveschema"
+)
+
+func must(t *testing.T, err error) {
+	t.Helper()
+	if err != nil {
+		t.Fatal(err)
+	}
+}
+
+func testStore(t *testing.T, s Store) {
+	t.Run("Updates", func(t *testing.T) {
+		testUpdates(t, s)
+	})
+	t.Run("CVEs", func(t *testing.T) {
+		testCVEs(t, s)
+	})
+}
+
+func testUpdates(t *testing.T, s Store) {
+	ctx := context.Background()
+	start := time.Date(2021, time.September, 1, 0, 0, 0, 0, time.Local)
+
+	u1 := &UpdateRecord{
+		StartedAt:  start,
+		CommitHash: "abc",
+		NumTotal:   100,
+	}
+	must(t, s.CreateUpdateRecord(ctx, u1))
+	u1.EndedAt = u1.StartedAt.Add(10 * time.Minute)
+	u1.NumAdded = 100
+	must(t, s.SetUpdateRecord(ctx, u1))
+	u2 := &UpdateRecord{
+		StartedAt:  start.Add(time.Hour),
+		CommitHash: "def",
+		NumTotal:   80,
+	}
+	must(t, s.CreateUpdateRecord(ctx, u2))
+	u2.EndedAt = u2.StartedAt.Add(8 * time.Minute)
+	u2.NumAdded = 40
+	u2.NumModified = 40
+	must(t, s.SetUpdateRecord(ctx, u2))
+	got, err := s.ListUpdateRecords(ctx)
+	if err != nil {
+		t.Fatal(err)
+	}
+	want := []*UpdateRecord{u2, u1}
+	diff(t, want, got, cmpopts.IgnoreFields(UpdateRecord{}, "UpdatedAt"))
+	for _, g := range got {
+		if g.UpdatedAt.IsZero() {
+			t.Error("zero UpdatedAt field")
+		}
+	}
+}
+
+func testCVEs(t *testing.T, s Store) {
+	ctx := context.Background()
+	const (
+		id1 = "CVE-1905-0001"
+		id2 = "CVE-1905-0002"
+		id3 = "CVE-1905-0003"
+	)
+	crs := []*CVERecord{
+		{
+			ID:          id1,
+			Path:        "1905/" + id1 + ".json",
+			BlobHash:    "123",
+			CommitHash:  "456",
+			CVEState:    "PUBLIC",
+			TriageState: TriageStateNeedsIssue,
+		},
+		{
+			ID:          id2,
+			Path:        "1906/" + id2 + ".json",
+			BlobHash:    "abc",
+			CommitHash:  "def",
+			CVEState:    "RESERVED",
+			TriageState: TriageStateNoActionNeeded,
+		},
+		{
+			ID:          id3,
+			Path:        "1907/" + id3 + ".json",
+			BlobHash:    "xyz",
+			CommitHash:  "456",
+			CVEState:    "REJECT",
+			TriageState: TriageStateNoActionNeeded,
+		},
+	}
+
+	getCVERecords := func(startID, endID string) []*CVERecord {
+		var got []*CVERecord
+		err := s.RunTransaction(ctx, func(ctx context.Context, tx Transaction) error {
+			var err error
+			got, err = tx.GetCVERecords(startID, endID)
+			return err
+		})
+		if err != nil {
+			t.Fatal(err)
+		}
+		return got
+	}
+
+	getCVERecord := func(id string) *CVERecord {
+		return getCVERecords(id, id)[0]
+	}
+
+	createCVERecords(t, ctx, s, crs)
+
+	diff(t, crs[:1], getCVERecords(id1, id1))
+	diff(t, crs[1:], getCVERecords(id2, id3))
+
+	// Test SetCVERecord.
+
+	set := func(r *CVERecord) *CVERecord {
+		err := s.RunTransaction(ctx, func(ctx context.Context, tx Transaction) error {
+			return tx.SetCVERecord(r)
+		})
+		if err != nil {
+			t.Fatal(err)
+		}
+		return getCVERecord(r.ID)
+	}
+
+	// Make sure the first record is the same that we created.
+	got := getCVERecord(id1)
+	diff(t, crs[0], got)
+
+	// Change the state and the commit hash.
+	got.CVEState = cveschema.StateRejected
+	got.CommitHash = "999"
+	set(got)
+	want := *crs[0]
+	want.CVEState = cveschema.StateRejected
+	want.CommitHash = "999"
+	diff(t, &want, got)
+}
+
+func createCVERecords(t *testing.T, ctx context.Context, s Store, crs []*CVERecord) {
+	err := s.RunTransaction(ctx, func(ctx context.Context, tx Transaction) error {
+		for _, cr := range crs {
+			if err := tx.CreateCVERecord(cr); err != nil {
+				return err
+			}
+		}
+		return nil
+	})
+	if err != nil {
+		t.Fatal(err)
+	}
+}
+
+func diff(t *testing.T, want, got interface{}, opts ...cmp.Option) {
+	t.Helper()
+	if diff := cmp.Diff(want, got, opts...); diff != "" {
+		t.Errorf("mismatch (-want, +got):\n%s", diff)
+	}
+}