x/vulndb: add client and cli for managing CVE IDs

Adds new internal package cveclient, a Go client for the MITRE CVE
Services API. Implements functionality to reserve new IDs, lookup
existing IDs, lookup quota, and list IDs for an organization.

Also adds a command line tool 'cve' to call the client functions.

For golang/go#53256

Change-Id: I10fad48adbdac32485ddf05975e2604021607079
Reviewed-on: https://go-review.googlesource.com/c/vulndb/+/409995
Reviewed-by: Julie Qiu <julieqiu@google.com>
Reviewed-by: Tatiana Bradley <tatiana@golang.org>
Auto-Submit: Tatiana Bradley <tatiana@golang.org>
TryBot-Result: Gopher Robot <gobot@golang.org>
Run-TryBot: Julie Qiu <julieqiu@google.com>
diff --git a/cmd/cve/main.go b/cmd/cve/main.go
new file mode 100644
index 0000000..f70799e
--- /dev/null
+++ b/cmd/cve/main.go
@@ -0,0 +1,212 @@
+// Copyright 2022 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.
+
+// Command cve provides utilities for managing CVE IDs and CVE Records via the
+// MITRE CVE Services API.
+package main
+
+import (
+	"errors"
+	"flag"
+	"fmt"
+	"log"
+	"os"
+	"regexp"
+	"time"
+
+	"golang.org/x/vulndb/internal/cveclient"
+)
+
+var (
+	apiKey = flag.String("key",
+		os.Getenv("CVE_API_KEY"), "key for accessing the CVE API (can also be set via env var CVE_API_KEY)")
+	apiUser = flag.String("user",
+		os.Getenv("CVE_API_USER"), "username for accessing the CVE API (can also be set via env var CVE_API_USER)")
+	apiOrg = flag.String("org",
+		"Go", "organization name for accessing the CVE API")
+	test = flag.Bool("test", false, "whether to access the CVE API in the test environment")
+
+	// flags for the reserve command
+	reserveN          = flag.Int("n", 1, "reserve: the number of new CVE IDs to reserve")
+	reserveSequential = flag.Bool("seq", true, "reserve: if true, reserve new CVE ID batches in sequence")
+
+	// flags for the list command
+	listState = flag.String("state", "", "list: filter by CVE state (RESERVED, PUBLIC, or REJECT)")
+
+	// flags that apply to multiple commands
+	year = flag.Int("year", 0, "reserve: the CVE ID year for newly reserved CVE IDs (default is current year)\nlist: filter by the year in the CVE ID")
+)
+
+func main() {
+	out := flag.CommandLine.Output()
+	flag.Usage = func() {
+		fmt.Fprintln(out, "Command cve provides utilities for managing CVE IDs and CVE Records via the MITRE CVE Services API")
+		formatCmd := "    %s: %s\n"
+		fmt.Fprintf(out, "usage: cve [-key] [-user] [-org] [-test] <cmd> ...\n  commands:\n")
+		fmt.Fprintf(out, formatCmd, "[-n] [-seq] [-year] reserve", "reserves new CVE IDs")
+		fmt.Fprintf(out, formatCmd, "quota", "outputs the CVE ID quota of an organization")
+		fmt.Fprintf(out, formatCmd, "lookup {cve-id}", "outputs details on an assigned CVE ID (CVE-YYYY-NNNN)")
+		fmt.Fprintf(out, formatCmd, "[-year] [-state] list", "lists all CVE IDs for an organization")
+		flag.PrintDefaults()
+	}
+
+	flag.Parse()
+	if flag.NArg() < 1 {
+		logUsageErr("cve", fmt.Errorf("must provide subcommand"))
+	}
+
+	// The cve tool does not currently support the dev endpoint as there is
+	// no clear use case for us.
+	endpoint := cveclient.ProdEndpoint
+	if *test {
+		endpoint = cveclient.TestEndpoint
+	}
+
+	if *apiKey == "" {
+		logUsageErr("cve", errors.New("the CVE API key (flag -key or env var CVE_API_KEY) must be set"))
+	}
+	if *apiUser == "" {
+		logUsageErr("cve", errors.New("the CVE API user (flag -user or env var CVE_API_USER) must be set"))
+	}
+
+	cfg := cveclient.Config{
+		Endpoint: endpoint,
+		Key:      *apiKey,
+		Org:      *apiOrg,
+		User:     *apiUser,
+	}
+	c := cveclient.New(cfg)
+
+	cmd := flag.Arg(0)
+	switch cmd {
+	case "help":
+		flag.Usage()
+	case "reserve":
+		year := *year
+		if year == 0 {
+			year = getCurrentYear()
+		}
+		mode := cveclient.SequentialRequest
+		if !*reserveSequential {
+			mode = cveclient.NonsequentialRequest
+		}
+		if err := reserve(c, cveclient.ReserveOptions{
+			NumIDs: *reserveN,
+			Year:   year,
+			Mode:   mode,
+		}); err != nil {
+			log.Fatalf("cve reserve: could not reserve any new CVEs due to error:\n  %v", err)
+		}
+	case "quota":
+		if err := quota(c); err != nil {
+			log.Fatalf("cve quota: could not retrieve quota info due to error:\n  %v", err)
+		}
+	case "lookup":
+		id, err := validateID(flag.Arg(1))
+		if err != nil {
+			logUsageErr("cve lookup", err)
+		}
+		if err := lookup(c, id); err != nil {
+			log.Fatalf("cve lookup: could not retrieve CVE IDs due to error:\n  %v", err)
+		}
+	case "list":
+		// TODO(http://go.dev/issues/53258): allow time-based filters via flags.
+		var filters *cveclient.ListOptions
+		if *listState != "" || *year != 0 {
+			filters = new(cveclient.ListOptions)
+			state, err := validateState(*listState)
+			if err != nil {
+				logUsageErr("cve list", err)
+			}
+			filters.State = state
+			filters.Year = *year
+		}
+		if err := list(c, filters); err != nil {
+			log.Fatalf("cve list: could not retrieve CVE IDs due to error:\n  %v", err)
+		}
+	default:
+		logUsageErr("cve", fmt.Errorf("unsupported command: %q", cmd))
+	}
+}
+
+func logUsageErr(context string, err error) {
+	log.Printf("%s: %s\n\n", context, err)
+	flag.Usage()
+	os.Exit(1)
+}
+
+func getCurrentYear() int {
+	year, _, _ := time.Now().Date()
+	return year
+}
+
+var cveRegex = regexp.MustCompile(`^CVE-\d{4}-\d{4,}$`)
+
+func validateID(id string) (string, error) {
+	if id == "" {
+		return "", errors.New("CVE ID must be provided")
+	}
+	if !cveRegex.MatchString(id) {
+		return "", fmt.Errorf("%q is not a valid CVE ID", id)
+	}
+	return id, nil
+}
+
+var stateRegex = regexp.MustCompile(`^(RESERVED|PUBLIC|REJECT)$`)
+
+func validateState(state string) (string, error) {
+	if state != "" && !stateRegex.MatchString(state) {
+		return "", fmt.Errorf("state must match regex %v", stateRegex)
+	}
+	return state, nil
+}
+
+func reserve(c *cveclient.Client, opts cveclient.ReserveOptions) error {
+	cves, err := c.ReserveIDs(opts)
+	if err != nil {
+		return err
+	}
+	cvesReserved := len(cves)
+	if cvesReserved < opts.NumIDs {
+		fmt.Printf("warning: only %d of %d requested CVE IDs were reserved\n",
+			len(cves), opts.NumIDs)
+	}
+	fmt.Printf("successfully reserved %d CVE IDs:\n  %v\n", cvesReserved, cves.ShortString())
+	return nil
+}
+
+func quota(c *cveclient.Client) error {
+	quota, err := c.RetrieveQuota()
+	if err != nil {
+		return err
+	}
+	fmt.Printf("quota info for org %q:\n  quota: %d\n  total reserved: %d\n  available:  %d\n", c.Org, quota.Quota, quota.Reserved, quota.Available)
+	return nil
+}
+
+func lookup(c *cveclient.Client, id string) error {
+	cve, err := c.RetrieveCVE(id)
+	if err != nil {
+		return err
+	}
+	fmt.Println(cve)
+	return nil
+}
+
+func list(c *cveclient.Client, lf *cveclient.ListOptions) error {
+	cves, err := c.ListOrgCVEs(lf)
+	if err != nil {
+		return err
+	}
+	var filterString string
+	if lf != nil {
+		filterString = fmt.Sprintf(" with filters %s", lf)
+	}
+	if n := len(cves); n > 0 {
+		fmt.Printf("found %d CVE IDs for org %q%s:\n%v\n", n, c.Org, filterString, cves)
+	} else {
+		fmt.Printf("found no CVE IDs for org %q%s\n", c.Org, filterString)
+	}
+	return nil
+}
diff --git a/internal/cveclient/cveclient.go b/internal/cveclient/cveclient.go
new file mode 100644
index 0000000..0c22fb9
--- /dev/null
+++ b/internal/cveclient/cveclient.go
@@ -0,0 +1,378 @@
+// Copyright 2022 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 cveclient implements a client for interacting with MITRE CVE
+// Services API as described at https://cveawg.mitre.org/api-docs/openapi.json.
+package cveclient
+
+import (
+	"encoding/json"
+	"fmt"
+	"io"
+	"net/http"
+	"net/url"
+	"strconv"
+	"strings"
+	"time"
+)
+
+const (
+	// ProdEndpoint is the production endpoint
+	ProdEndpoint = "https://cveawg.mitre.org"
+	// TestEndpoint is the test endpoint
+	TestEndpoint = "https://cveawg-test.mitre.org"
+	// DevEndpoint is the dev endpoint
+	DevEndpoint = "https://cveawg-dev.mitre.org"
+)
+
+// Client is a MITRE CVE Services API client.
+type Client struct {
+	Config
+	c *http.Client
+}
+
+// Config contains client configuration data.
+type Config struct {
+	// Endpoint is the endpoint to access when making API calls. Required.
+	Endpoint string
+	// Org is the shortname for the organization that is authenticated when
+	// making API calls. Required.
+	Org string
+	// Key is the user's API key. Required.
+	Key string
+	// User is the username for the account that is making API calls. Required.
+	User string
+}
+
+// New returns an initialized client configured via cfg.
+func New(cfg Config) *Client {
+	return &Client{cfg, http.DefaultClient}
+}
+
+// AssignedCVE contains information about an assigned CVE.
+type AssignedCVE struct {
+	ID          string      `json:"cve_id"`
+	Year        string      `json:"cve_year"`
+	State       string      `json:"state"`
+	CNA         string      `json:"owning_cna"`
+	Reserved    time.Time   `json:"reserved"`
+	RequestedBy RequestedBy `json:"requested_by"`
+}
+
+// RequestedBy indicates the requesting user and organization for a CVE.
+type RequestedBy struct {
+	CNA  string `json:"cna"`
+	User string `json:"user"`
+}
+
+func (c AssignedCVE) String() string {
+	return fmt.Sprintf("%s: state=%s, cna=%s, requester=%s", c.ID, c.State, c.CNA, c.RequestedBy.User)
+}
+
+// AssignedCVEList is a list of AssignedCVEs.
+type AssignedCVEList []AssignedCVE
+
+// ShortString outputs a formatted string of comma-separated CVE IDs.
+func (cs AssignedCVEList) ShortString() string {
+	strs := []string{}
+	for _, c := range cs {
+		strs = append(strs, c.ID)
+	}
+	return strings.Join(strs, ", ")
+}
+
+// String outputs a formatted string of newline-separated CVE data.
+func (cs AssignedCVEList) String() string {
+	strs := []string{}
+	for _, c := range cs {
+		strs = append(strs, c.String())
+	}
+	return strings.Join(strs, "\n")
+}
+
+// ReserveOptions contains the configuration options for reserving new
+// CVE IDs.
+type ReserveOptions struct {
+	// NumIDs is the the number of CVE IDs to reserve. Required.
+	NumIDs int
+	// Year is the CVE ID year for new IDs, indicating the year the
+	// vulnerability was discovered. Required.
+	Year int
+	// Mode indicates whether the block of CVEs should be in sequence.
+	// Relevant only if NumIDs > 1.
+	Mode RequestType
+}
+
+// RequestType is the type of CVE ID reserve request.
+type RequestType string
+
+const (
+	// SequentialRequest requests CVE IDs be reserved in a sequential fashion.
+	SequentialRequest RequestType = "sequential"
+	// NonsequentialRequest requests CVE IDs be reserved in a nonsequential fashion.
+	NonsequentialRequest RequestType = "nonsequential"
+)
+
+func (o *ReserveOptions) getURLParams(org string) url.Values {
+	params := url.Values{}
+	params.Set("amount", fmt.Sprint(o.NumIDs))
+	if o.Year != 0 {
+		params.Set("cve_year", strconv.Itoa(o.Year))
+	}
+	params.Set("short_name", org)
+	if o.NumIDs > 1 {
+		params.Set("batch_type", string(o.Mode))
+	}
+	return params
+}
+
+func (c *Client) createReserveIDsRequest(opts ReserveOptions) (*http.Request, error) {
+	req, err := c.createRequest(http.MethodPost,
+		fmt.Sprintf("%s/api/cve-id", c.Endpoint))
+	if err != nil {
+		return nil, err
+	}
+	req.URL.RawQuery = opts.getURLParams(c.Org).Encode()
+	return req, nil
+}
+
+type reserveIDsResponse struct {
+	CVEs AssignedCVEList `json:"cve_ids"`
+}
+
+// ReserveIDs sends a request to the CVE API to reserve a block of CVE IDs.
+// Returns a list of the reserved CVE IDs and their associated data.
+// There may be fewer IDs than requested if, for example, the organization's
+// quota is reached.
+func (c *Client) ReserveIDs(opts ReserveOptions) (AssignedCVEList, error) {
+	req, err := c.createReserveIDsRequest(opts)
+	if err != nil {
+		return nil, err
+	}
+	var assigned reserveIDsResponse
+	checkStatus := func(s int) bool {
+		return s == http.StatusOK || s == http.StatusPartialContent
+	}
+	err = c.sendRequest(req, checkStatus, &assigned)
+	if err != nil {
+		return nil, err
+	}
+	return assigned.CVEs, nil
+}
+
+// Quota contains information about an organizations reservation quota.
+type Quota struct {
+	Quota     int `json:"id_quota"`
+	Reserved  int `json:"total_reserved"`
+	Available int `json:"available"`
+}
+
+// RetrieveQuota queries the API for the organizations reservation quota.
+func (c *Client) RetrieveQuota() (Quota, error) {
+	req, err := c.createRequest(http.MethodGet, fmt.Sprintf("%s/api/org/%s/id_quota", c.Endpoint, c.Org))
+	if err != nil {
+		return Quota{}, err
+	}
+
+	var q Quota
+	err = c.sendRequest(req, nil, &q)
+	if err != nil {
+		return Quota{}, err
+	}
+	return q, nil
+}
+
+// RetrieveCVE requests information about an assigned CVE ID.
+func (c *Client) RetrieveCVE(id string) (AssignedCVE, error) {
+	req, err := c.createRequest(http.MethodGet, fmt.Sprintf("%s/api/cve-id/%s", c.Endpoint, id))
+	if err != nil {
+		return AssignedCVE{}, err
+	}
+
+	var cve AssignedCVE
+	err = c.sendRequest(req, nil, &cve)
+	if err != nil {
+		return AssignedCVE{}, err
+	}
+	return cve, nil
+}
+
+// ListOptions contains filters to be used when requesting a list of
+// assigned CVEs.
+type ListOptions struct {
+	State          string
+	Year           int
+	ReservedBefore *time.Time
+	ReservedAfter  *time.Time
+	ModifiedBefore *time.Time
+	ModifiedAfter  *time.Time
+}
+
+func (o ListOptions) String() string {
+	var s []string
+	if o.State != "" {
+		s = append(s, fmt.Sprintf("state=%s", o.State))
+	}
+	if o.Year != 0 {
+		s = append(s, fmt.Sprintf("year=%d", o.Year))
+	}
+	if o.ReservedBefore != nil {
+		s = append(s, fmt.Sprintf("reserved_before=%s", o.ReservedBefore.Format(time.RFC3339)))
+	}
+	if o.ReservedAfter != nil {
+		s = append(s, fmt.Sprintf("reserved_after=%s", o.ReservedAfter.Format(time.RFC3339)))
+	}
+	if o.ModifiedBefore != nil {
+		s = append(s, fmt.Sprintf("modified_before=%s", o.ModifiedBefore.Format(time.RFC3339)))
+	}
+	if o.ModifiedAfter != nil {
+		s = append(s, fmt.Sprintf("modified_after=%s", o.ModifiedAfter.Format(time.RFC3339)))
+	}
+	return strings.Join(s, ", ")
+}
+
+func (o *ListOptions) getURLParams() url.Values {
+	params := url.Values{}
+	if o.State != "" {
+		params.Set("state", o.State)
+	}
+	if o.Year != 0 {
+		params.Set("cve_id_year", strconv.Itoa(o.Year))
+	}
+	if o.ReservedBefore != nil {
+		params.Set("time_reserved.lt", o.ReservedBefore.Format(time.RFC3339))
+	}
+	if o.ReservedAfter != nil {
+		params.Set("time_reserved.gt", o.ReservedAfter.Format(time.RFC3339))
+	}
+	if o.ModifiedBefore != nil {
+		params.Set("time_modified.lt", o.ModifiedBefore.Format(time.RFC3339))
+	}
+	if o.ModifiedAfter != nil {
+		params.Set("time_modified.gt", o.ModifiedAfter.Format(time.RFC3339))
+	}
+	return params
+}
+
+type listOrgCVEsResponse struct {
+	CurrentPage int             `json:"currentPage"`
+	NextPage    int             `json:"nextPage"`
+	CVEs        AssignedCVEList `json:"cve_ids"`
+}
+
+func (c Client) createListOrgCVEsRequest(opts *ListOptions, page int) (*http.Request, error) {
+	req, err := c.createRequest(http.MethodGet, fmt.Sprintf("%s/api/cve-id", c.Endpoint))
+	if err != nil {
+		return nil, err
+	}
+	params := url.Values{}
+	if opts != nil {
+		params = opts.getURLParams()
+	}
+	if page > 0 {
+		params.Set("page", fmt.Sprint(page))
+	}
+	req.URL.RawQuery = params.Encode()
+	return req, nil
+}
+
+// ListOrgCVEs requests information about the CVEs the organization has been
+// assigned. This list can be filtered by setting the fields in opts.
+func (c *Client) ListOrgCVEs(opts *ListOptions) (AssignedCVEList, error) {
+	var cves []AssignedCVE
+	page := 0
+	for {
+		req, err := c.createListOrgCVEsRequest(opts, page)
+		if err != nil {
+			return nil, err
+		}
+		var result listOrgCVEsResponse
+		err = c.sendRequest(req, nil, &result)
+		if err != nil {
+			return nil, err
+		}
+		cves = append(cves, result.CVEs...)
+		if result.NextPage <= result.CurrentPage {
+			break
+		}
+		page = result.NextPage
+	}
+	return cves, nil
+}
+
+var (
+	headerApiUser = "CVE-API-USER"
+	headerApiOrg  = "CVE-API-ORG"
+	headerApiKey  = "CVE-API-KEY"
+)
+
+// createRequest creates a new HTTP request and sets the header fields.
+func (c *Client) createRequest(method, url string) (*http.Request, error) {
+	req, err := http.NewRequest(method, url, nil)
+	if err != nil {
+		return nil, err
+	}
+	req.Header.Set(headerApiUser, c.User)
+	req.Header.Set(headerApiOrg, c.Org)
+	req.Header.Set(headerApiKey, c.Key)
+	return req, nil
+}
+
+// sendRequest sends an HTTP request, checks the returned status via
+// checkStatus, and attempts to unmarshal the response into result.
+// if checkStatus is nil, checks for http.StatusOK.
+func (c *Client) sendRequest(req *http.Request, checkStatus func(int) bool, result any) (err error) {
+	resp, err := c.c.Do(req)
+	if err != nil {
+		return fmt.Errorf("could not send HTTP request: %v", err)
+	}
+	defer resp.Body.Close()
+	if checkStatus == nil {
+		checkStatus = func(s int) bool {
+			return s == http.StatusOK
+		}
+	}
+	if !checkStatus(resp.StatusCode) {
+		return fmt.Errorf("HTTP request %s %q returned error: %w", req.Method, req.URL, extractError(resp))
+	}
+	body, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return err
+	}
+	if err := json.Unmarshal(body, result); err != nil {
+		return err
+	}
+	return nil
+}
+
+type apiError struct {
+	Error   string `json:"error"`
+	Message string `json:"message"`
+}
+
+// extractError extracts additional error messages from the HTTP response
+// if available, and wraps them into a single error.
+func extractError(resp *http.Response) error {
+	errMsg := resp.Status
+	body, err := io.ReadAll(resp.Body)
+	if err != nil {
+		// Discard the read error and return the HTTP status.
+		return fmt.Errorf(errMsg)
+	}
+	var apiErr apiError
+	if err := json.Unmarshal(body, &apiErr); err != nil {
+		// Discard the unmarshal error and return the HTTP status.
+		return fmt.Errorf(errMsg)
+	}
+
+	// Append the error and message text if they add extra information
+	// beyond the HTTP status text.
+	statusText := strings.ToLower(http.StatusText(resp.StatusCode))
+	for _, errText := range []string{apiErr.Error, apiErr.Message} {
+		if errText != "" && strings.ToLower(errText) != statusText {
+			errMsg = fmt.Sprintf("%s: %s", errMsg, errText)
+		}
+	}
+	return fmt.Errorf(errMsg)
+}
diff --git a/internal/cveclient/cveclient_test.go b/internal/cveclient/cveclient_test.go
new file mode 100644
index 0000000..b637807
--- /dev/null
+++ b/internal/cveclient/cveclient_test.go
@@ -0,0 +1,402 @@
+// Copyright 2022 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 cveclient
+
+import (
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"net/http/httptest"
+	"net/url"
+	"reflect"
+	"strconv"
+	"strings"
+	"testing"
+	"time"
+
+	"golang.org/x/vulndb/internal/cveschema"
+)
+
+var (
+	testApiKey  = "test_api_key"
+	testApiOrg  = "test_api_org"
+	testApiUser = "test_api_user"
+)
+
+var defaultTestCVE = newTestCVE("CVE-2022-0000", cveschema.StateReserved, "2022")
+var defaultTestCVEs = AssignedCVEList{
+	defaultTestCVE, newTestCVE("CVE-2022-0001", cveschema.StateReserved, "2022"),
+}
+var defaultTestQuota = Quota{
+	Quota:     10,
+	Reserved:  3,
+	Available: 7,
+}
+
+var (
+	testTime2022 = time.Date(2022, 1, 1, 0, 0, 0, 0, time.UTC)
+	testTime2000 = time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC)
+	testTime1999 = time.Date(1999, 1, 1, 0, 0, 0, 0, time.UTC)
+	testTime1992 = time.Date(1992, 1, 1, 0, 0, 0, 0, time.UTC)
+)
+
+func newTestCVE(id, state, year string) AssignedCVE {
+	return AssignedCVE{
+		ID:       id,
+		Year:     year,
+		State:    state,
+		CNA:      testApiOrg,
+		Reserved: testTime2022,
+		RequestedBy: RequestedBy{
+			CNA:  testApiOrg,
+			User: testApiUser,
+		},
+	}
+}
+
+func newTestClientAndServer(handler http.HandlerFunc) (*Client, *httptest.Server) {
+	s := httptest.NewServer(http.HandlerFunc(handler))
+	c := New(Config{
+		Endpoint: s.URL,
+		Key:      testApiKey,
+		Org:      testApiOrg,
+		User:     testApiUser})
+	c.c = s.Client()
+	return c, s
+}
+
+func checkHeaders(t *testing.T, r *http.Request) {
+	if got, want := r.Header.Get(headerApiUser), testApiUser; got != want {
+		t.Errorf("HTTP Header %q = %s, want %s", headerApiUser, got, want)
+	}
+	if got, want := r.Header.Get(headerApiOrg), testApiOrg; got != want {
+		t.Errorf("HTTP Header %q = %s, want %s", headerApiOrg, got, want)
+	}
+	if got, want := r.Header.Get(headerApiKey), testApiKey; got != want {
+		t.Errorf("HTTP Header %q = %s, want %s", headerApiKey, got, want)
+	}
+}
+
+func newTestHandler(t *testing.T, mockStatus int, mockResponse any, validateRequest func(t *testing.T, r *http.Request)) http.HandlerFunc {
+	mr, err := json.Marshal(mockResponse)
+	if err != nil {
+		t.Fatalf("could not marshal mock response: %v", err)
+	}
+	return func(w http.ResponseWriter, r *http.Request) {
+		if validateRequest != nil {
+			validateRequest(t, r)
+		}
+		checkHeaders(t, r)
+		w.WriteHeader(mockStatus)
+		w.Write(mr)
+	}
+}
+
+func newTestHandlerMultiPage(t *testing.T, mockResponses []any, validateRequest func(t *testing.T, r *http.Request)) http.HandlerFunc {
+	var mrs [][]byte
+	for _, r := range mockResponses {
+		mr, err := json.Marshal(r)
+		if err != nil {
+			t.Fatalf("could not marshal mock response: %v", err)
+		}
+		mrs = append(mrs, mr)
+	}
+	return func(w http.ResponseWriter, r *http.Request) {
+		if validateRequest != nil {
+			validateRequest(t, r)
+		}
+		parsed, err := url.ParseQuery(r.URL.RawQuery)
+		if err != nil {
+			t.Fatalf("could not parse URL query: %v", err)
+		}
+		var page int
+		if pages := parsed["page"]; len(pages) >= 1 {
+			page, err = strconv.Atoi(parsed["page"][0])
+			if err != nil {
+				t.Fatalf("could not parse page as int: %v", err)
+			}
+		}
+		checkHeaders(t, r)
+		w.WriteHeader(http.StatusOK)
+		w.Write(mrs[page])
+	}
+}
+
+func TestCreateReserveIDsRequest(t *testing.T) {
+	tests := []struct {
+		opts       ReserveOptions
+		wantParams string
+	}{
+		{
+			opts: ReserveOptions{
+				NumIDs: 1,
+				Year:   2000,
+				Mode:   SequentialRequest,
+			},
+			wantParams: "amount=1&cve_year=2000&short_name=test_api_org",
+		},
+		{
+			opts: ReserveOptions{
+				NumIDs: 2,
+				Year:   2022,
+				Mode:   SequentialRequest,
+			},
+			wantParams: "amount=2&batch_type=sequential&cve_year=2022&short_name=test_api_org",
+		},
+		{
+			opts: ReserveOptions{
+				NumIDs: 3,
+				Year:   2010,
+				Mode:   NonsequentialRequest,
+			},
+			wantParams: "amount=3&batch_type=nonsequential&cve_year=2010&short_name=test_api_org",
+		},
+	}
+	for _, test := range tests {
+		t.Run(fmt.Sprintf("NumIDs=%d/Year=%d/Mode=%s", test.opts.NumIDs, test.opts.Year, test.opts.Mode), func(t *testing.T) {
+			c, s := newTestClientAndServer(nil)
+			defer s.Close()
+			req, err := c.createReserveIDsRequest(test.opts)
+			if err != nil {
+				t.Fatalf("unexpected error getting reserve ID request: %v", err)
+			}
+			if got, want := req.URL.RawQuery, test.wantParams; got != want {
+				t.Errorf("incorrect request params: got %v, want %v", got, want)
+			}
+		})
+	}
+}
+
+type queryFunc func(c *Client) (any, error)
+
+var (
+	reserveIDsQuery = func(c *Client) (any, error) {
+		return c.ReserveIDs(ReserveOptions{
+			NumIDs: 2,
+			Year:   2002,
+			Mode:   SequentialRequest,
+		})
+	}
+	retrieveQuotaQuery = func(c *Client) (any, error) {
+		return c.RetrieveQuota()
+	}
+	retrieveCVEQuery = func(c *Client) (any, error) {
+		return c.RetrieveCVE(defaultTestCVE.ID)
+	}
+	listOrgCVEsQuery = func(c *Client) (any, error) {
+		return c.ListOrgCVEs(&ListOptions{})
+	}
+)
+
+func TestAllSuccess(t *testing.T) {
+	tests := []struct {
+		name           string
+		mockStatus     int
+		mockResponse   any
+		query          queryFunc
+		wantHTTPMethod string
+		wantPath       string
+		want           any
+	}{
+		{
+			name:       "ReserveIDs",
+			query:      reserveIDsQuery,
+			mockStatus: http.StatusOK,
+			mockResponse: reserveIDsResponse{
+				CVEs: defaultTestCVEs},
+			wantHTTPMethod: http.MethodPost,
+			wantPath:       "/api/cve-id",
+			want:           defaultTestCVEs,
+		},
+		{
+			name:       "ReserveIDs/partial content ok",
+			query:      reserveIDsQuery,
+			mockStatus: http.StatusPartialContent,
+			mockResponse: reserveIDsResponse{
+				CVEs: AssignedCVEList{defaultTestCVE}},
+			wantHTTPMethod: http.MethodPost,
+			wantPath:       "/api/cve-id",
+			want:           AssignedCVEList{defaultTestCVE},
+		},
+		{
+			name:           "RetrieveQuota",
+			query:          retrieveQuotaQuery,
+			mockStatus:     http.StatusOK,
+			mockResponse:   defaultTestQuota,
+			wantHTTPMethod: http.MethodGet,
+			wantPath:       "/api/org/test_api_org/id_quota",
+			want:           defaultTestQuota,
+		},
+		{
+			name:           "RetrieveCVE",
+			query:          retrieveCVEQuery,
+			mockStatus:     http.StatusOK,
+			mockResponse:   defaultTestCVE,
+			wantHTTPMethod: http.MethodGet,
+			wantPath:       "/api/cve-id/CVE-2022-0000",
+			want:           defaultTestCVE,
+		},
+		{
+			name:       "ListOrgCVEs/single page",
+			query:      listOrgCVEsQuery,
+			mockStatus: http.StatusOK,
+			mockResponse: listOrgCVEsResponse{
+				CurrentPage: 0,
+				NextPage:    -1,
+				CVEs:        defaultTestCVEs,
+			},
+			wantHTTPMethod: http.MethodGet,
+			wantPath:       "/api/cve-id",
+			want:           defaultTestCVEs,
+		},
+	}
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			validateRequest := func(t *testing.T, r *http.Request) {
+				if got, want := r.Method, test.wantHTTPMethod; got != want {
+					t.Errorf("incorrect HTTP method: got %v, want %v", got, want)
+				}
+				if got, want := r.URL.Path, test.wantPath; got != want {
+					t.Errorf("incorrect request URL path: got %v, want %v", got, want)
+				}
+			}
+			c, s := newTestClientAndServer(
+				newTestHandler(t, test.mockStatus, test.mockResponse, validateRequest))
+			defer s.Close()
+			got, err := test.query(c)
+			if err != nil {
+				t.Fatalf("unexpected error: %v", err)
+			}
+			if want := test.want; !reflect.DeepEqual(got, want) {
+				t.Errorf("got %v, want %v", got, want)
+			}
+		})
+	}
+}
+
+func TestAllFail(t *testing.T) {
+	tests := []struct {
+		name  string
+		query queryFunc
+	}{
+		{
+			name:  "ReserveIDs",
+			query: reserveIDsQuery,
+		},
+		{
+			name:  "RetrieveQuota",
+			query: retrieveQuotaQuery,
+		},
+		{
+			name:  "RetrieveCVE",
+			query: retrieveCVEQuery,
+		},
+		{
+			name:  "ListOrgCVEs",
+			query: listOrgCVEsQuery,
+		},
+	}
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			mockStatus := http.StatusUnauthorized
+			mockResponse := apiError{
+				Error:   "more info",
+				Message: "even more info",
+			}
+			c, s := newTestClientAndServer(newTestHandler(t, mockStatus, mockResponse, nil))
+			defer s.Close()
+			want := "401 Unauthorized: more info: even more info"
+			_, err := test.query(c)
+			if err == nil {
+				t.Fatalf("unexpected success: want err %v", want)
+			}
+			if got := err.Error(); !strings.Contains(got, want) {
+				t.Errorf("unexpected error string: got %v, want %v", got, want)
+			}
+		})
+	}
+}
+
+func TestCreateListOrgCVEsRequest(t *testing.T) {
+	tests := []struct {
+		opts       ListOptions
+		page       int
+		wantParams string
+	}{
+		{
+			opts: ListOptions{
+				State:          cveschema.StateReserved,
+				Year:           2000,
+				ReservedBefore: &testTime2022,
+				ReservedAfter:  &testTime1999,
+				ModifiedBefore: &testTime2000,
+				ModifiedAfter:  &testTime1992,
+			},
+			page:       0,
+			wantParams: "cve_id_year=2000&state=RESERVED&time_modified.gt=1992-01-01T00%3A00%3A00Z&time_modified.lt=2000-01-01T00%3A00%3A00Z&time_reserved.gt=1999-01-01T00%3A00%3A00Z&time_reserved.lt=2022-01-01T00%3A00%3A00Z",
+		},
+		{
+			opts: ListOptions{
+				State:          cveschema.StateRejected,
+				Year:           1999,
+				ReservedBefore: &testTime1999,
+				ReservedAfter:  &testTime2000,
+				ModifiedBefore: &testTime1992,
+				ModifiedAfter:  &testTime2022,
+			},
+			page:       1,
+			wantParams: "cve_id_year=1999&page=1&state=REJECT&time_modified.gt=2022-01-01T00%3A00%3A00Z&time_modified.lt=1992-01-01T00%3A00%3A00Z&time_reserved.gt=2000-01-01T00%3A00%3A00Z&time_reserved.lt=1999-01-01T00%3A00%3A00Z",
+		},
+		{
+			opts: ListOptions{
+				State: cveschema.StatePublic,
+				Year:  2000,
+			},
+			page:       2,
+			wantParams: "cve_id_year=2000&page=2&state=PUBLIC",
+		},
+	}
+	for _, test := range tests {
+		t.Run(fmt.Sprintf("State=%s/Year=%d/ReservedBefore=%s/ReservedAfter=%s/ModifiedBefore=%s/ModifiedAfter=%s", test.opts.State, test.opts.Year, test.opts.ReservedBefore, test.opts.ReservedAfter, test.opts.ModifiedBefore, test.opts.ModifiedAfter), func(t *testing.T) {
+			c, s := newTestClientAndServer(nil)
+			defer s.Close()
+			req, err := c.createListOrgCVEsRequest(&test.opts, test.page)
+			if err != nil {
+				t.Fatalf("unexpected error creating ListOrgCVEs request: %v", err)
+			}
+			if got, want := req.URL.RawQuery, test.wantParams; got != want {
+				t.Errorf("incorrect request params: got %v, want %v", got, want)
+			}
+		})
+	}
+}
+
+func TestListOrgCVEsMultiPage(t *testing.T) {
+	extraCVE := newTestCVE("CVE-2000-1234", cveschema.StateReserved, "2000")
+	mockResponses := []any{
+		listOrgCVEsResponse{
+			CurrentPage: 0,
+			NextPage:    1,
+			CVEs:        defaultTestCVEs,
+		},
+		listOrgCVEsResponse{
+			CurrentPage: 1,
+			NextPage:    -1,
+			CVEs:        AssignedCVEList{extraCVE},
+		},
+	}
+
+	c, s := newTestClientAndServer(
+		newTestHandlerMultiPage(t, mockResponses, nil))
+	defer s.Close()
+	got, err := c.ListOrgCVEs(nil)
+	if err != nil {
+		t.Fatalf("unexpected error listing org cves: %v", err)
+	}
+	want := append(defaultTestCVEs, extraCVE)
+	if !reflect.DeepEqual(got, want) {
+		t.Errorf("got %v, want %v", got, want)
+	}
+}