From 17970bde7cd46217630ed15a16d7aea8ada294d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B6ren=20Gade?= Date: Tue, 4 Aug 2026 17:26:13 +0200 Subject: [PATCH] feat: bring client up to date with JSON-RPC API Release 4 Basic API: add the missing generateIntegerSequences method, add replacement/pregeneratedRandomization/format optional parameters to every generate method via backward-compatible variadic MethodOptions structs, and enforce the n*size aggregate limit on generateBlobs that was previously unchecked. Add the Signed API (signed.go): all seven generateSigned* methods (plus ...WithBase variants for non-decimal integer bases), GetResult, and VerifySignature, returning a generic SignedResult[T] with signature, cost, license and usage metadata. Add the ticket lifecycle (tickets.go): CreateTickets, RevealTickets, ListTickets, GetTicket, and DecodeTicketResult. Extend the test suite with signed_test.go and tickets_test.go, built from the literal JSON examples published in the API docs, and add a runnable example for every exported method in example_test.go. Co-Authored-By: Claude Sonnet 5 --- README.md | 22 ++ basic.go | 332 +++++++++++++++--- basic_test.go | 272 +++++++++++++++ example_test.go | 301 +++++++++++++++++ randomorg.go | 26 ++ signed.go | 689 +++++++++++++++++++++++++++++++++++++ signed_test.go | 883 ++++++++++++++++++++++++++++++++++++++++++++++++ tickets.go | 261 ++++++++++++++ tickets_test.go | 447 ++++++++++++++++++++++++ usage.go | 5 +- 10 files changed, 3190 insertions(+), 48 deletions(-) create mode 100644 signed.go create mode 100644 signed_test.go create mode 100644 tickets.go create mode 100644 tickets_test.go diff --git a/README.md b/README.md index 8416045..fcb6f67 100644 --- a/README.md +++ b/README.md @@ -44,3 +44,25 @@ func main() { See the [GoDoc](https://godoc.org/github.com/sgade/randomorg) for the full API, or `example_test.go` for more runnable examples. + +## Basic vs. Signed API + +This client implements both halves of the Random.org [Core +API](https://api.random.org/json-rpc/4): + +- The [Basic API](https://api.random.org/json-rpc/4/basic) (`GenerateIntegers`, + `GenerateIntegerSequences`, `GenerateDecimalFractions`, `GenerateGaussians`, + `GenerateStrings`, `GenerateUUIDs`, `GenerateBlobs`) is the simplest way to + fetch true random values. +- The [Signed API](https://api.random.org/json-rpc/4/signed) + (`GenerateSignedIntegers` and friends, plus `GetResult` and + `VerifySignature`) additionally returns a cryptographic signature proving + the values came from RANDOM.ORG, along with support for tickets + (`CreateTickets`, `RevealTickets`, `ListTickets`, `GetTicket`) that let a + third party audit individual values without exposing your API key. + +Optional parameters for every method (e.g. `replacement`, +`pregeneratedRandomization`, and — for the Signed API — +`licenseData`/`userData`/`ticketId`) are passed as a trailing, optional +`MethodOptions` struct, so existing calls keep compiling unchanged when +upgrading. diff --git a/basic.go b/basic.go index a8db9f8..631edbb 100644 --- a/basic.go +++ b/basic.go @@ -34,15 +34,108 @@ func generate[T any](ctx context.Context, r *Random, method string, params any) return result.Random.Data, nil } +// PregeneratedRandomization selects historical, pregenerated randomness for +// a generate call instead of fresh, one-time randomness. Construct one with +// PregeneratedRandomizationByDate or PregeneratedRandomizationByID. A nil +// *PregeneratedRandomization (the default on every Options struct) requests +// fresh randomness, which RANDOM.ORG discards immediately after use. +// +// The non-nil forms turn RANDOM.ORG into a deterministic pseudo-random +// number generator: the same request replays the same values, which is +// useful for reproducing a draw or letting multiple parties derive the same +// values independently. +type PregeneratedRandomization struct { + Date string `json:"date,omitempty"` + ID string `json:"id,omitempty"` +} + +// PregeneratedRandomizationByDate uses the historical true randomness +// RANDOM.ORG generated on date (an ISO 8601 "YYYY-MM-DD" string), which must +// be today or in the past in UTC. +func PregeneratedRandomizationByDate(date string) *PregeneratedRandomization { + return &PregeneratedRandomization{Date: date} +} + +// PregeneratedRandomizationByID uses historical true randomness derived +// deterministically from id, a persistent identifier of length [1, 64]. +// Requesting the same id again reproduces the same values. +func PregeneratedRandomizationByID(id string) *PregeneratedRandomization { + return &PregeneratedRandomization{ID: id} +} + +// validatePregeneratedRandomization checks the constraints the API +// documentation places on PregeneratedRandomization.ID; Date is validated +// server-side since it must be compared against the current UTC date. +func validatePregeneratedRandomization(p *PregeneratedRandomization) error { + if p == nil { + return nil + } + if p.ID != "" && len(p.ID) > 64 { + return ErrParamRange + } + return nil +} + +// validateSequenceParams validates the parameters shared by +// GenerateIntegerSequences and GenerateSignedIntegerSequences: n sequences, +// each with its own length, lower bound and upper bound. +func validateSequenceParams(n int, length []int, min, max []int64) error { + if n < 1 || n > 1_000 { + return ErrParamRange + } + if len(length) != n || len(min) != n || len(max) != n { + return ErrParamRange + } + + sum := 0 + for i := range length { + if length[i] < 1 || length[i] > 10_000 { + return ErrParamRange + } + sum += length[i] + + if min[i] < -1_000_000_000 || min[i] > 1_000_000_000 { + return ErrParamRange + } + if max[i] < -1_000_000_000 || max[i] > 1_000_000_000 { + return ErrParamRange + } + } + if sum < 1 || sum > 10_000 { + return ErrParamRange + } + + return nil +} + +// Blob encoding formats accepted by GenerateBlobs and GenerateSignedBlobs. +const ( + BlobFormatBase64 = "base64" + BlobFormatHex = "hex" +) + +// GenerateIntegersOptions holds optional parameters for GenerateIntegers. +type GenerateIntegersOptions struct { + // Replacement specifies whether the numbers are picked with + // replacement. nil (the default) behaves like true: the result may + // contain duplicate values. Set to Bool(false) to draw unique values. + Replacement *bool + // PregeneratedRandomization selects historical randomness instead of + // fresh, on-the-fly randomness. nil (the default) uses fresh randomness. + PregeneratedRandomization *PregeneratedRandomization +} + type generateIntegersParams struct { baseParams - N int `json:"n"` - Min int64 `json:"min"` - Max int64 `json:"max"` + N int `json:"n"` + Min int64 `json:"min"` + Max int64 `json:"max"` + Replacement *bool `json:"replacement,omitempty"` + PregeneratedRandomization *PregeneratedRandomization `json:"pregeneratedRandomization,omitempty"` } // GenerateIntegers generates n number of random integers in the range from min to max. -func (r *Random) GenerateIntegers(ctx context.Context, n int, min, max int64) ([]int64, error) { +func (r *Random) GenerateIntegers(ctx context.Context, n int, min, max int64, opts ...GenerateIntegersOptions) ([]int64, error) { if n < 1 || n > 10_000 { return nil, ErrParamRange } @@ -50,24 +143,95 @@ func (r *Random) GenerateIntegers(ctx context.Context, n int, min, max int64) ([ return nil, ErrParamRange } + o := resolveOptions(opts) + if err := validatePregeneratedRandomization(o.PregeneratedRandomization); err != nil { + return nil, err + } + params := generateIntegersParams{ - baseParams: baseParams{APIKey: r.apiKey}, - N: n, - Min: min, - Max: max, + baseParams: baseParams{APIKey: r.apiKey}, + N: n, + Min: min, + Max: max, + Replacement: o.Replacement, + PregeneratedRandomization: o.PregeneratedRandomization, } return generate[int64](ctx, r, "generateIntegers", params) } +// GenerateIntegerSequencesOptions holds optional parameters for GenerateIntegerSequences. +type GenerateIntegerSequencesOptions struct { + // Replacement specifies, per sequence, whether its numbers are picked + // with replacement. If non-nil, it must have exactly n elements. nil + // (the default) behaves as if every sequence used replacement. + Replacement []bool + // PregeneratedRandomization selects historical randomness instead of + // fresh, on-the-fly randomness, for every sequence. nil (the default) + // uses fresh randomness. + PregeneratedRandomization *PregeneratedRandomization +} + +type generateIntegerSequencesParams struct { + baseParams + N int `json:"n"` + Length []int `json:"length"` + Min []int64 `json:"min"` + Max []int64 `json:"max"` + Replacement []bool `json:"replacement,omitempty"` + PregeneratedRandomization *PregeneratedRandomization `json:"pregeneratedRandomization,omitempty"` +} + +// GenerateIntegerSequences generates n sequences of random integers, where +// sequence i has length[i] elements drawn from the range [min[i], max[i]]. +// length, min and max must each have exactly n elements; to request +// identical sequences, repeat the same length/min/max in every slot. +func (r *Random) GenerateIntegerSequences(ctx context.Context, n int, length []int, min, max []int64, opts ...GenerateIntegerSequencesOptions) ([][]int64, error) { + if err := validateSequenceParams(n, length, min, max); err != nil { + return nil, err + } + + o := resolveOptions(opts) + if o.Replacement != nil && len(o.Replacement) != n { + return nil, ErrParamRange + } + if err := validatePregeneratedRandomization(o.PregeneratedRandomization); err != nil { + return nil, err + } + + params := generateIntegerSequencesParams{ + baseParams: baseParams{APIKey: r.apiKey}, + N: n, + Length: length, + Min: min, + Max: max, + Replacement: o.Replacement, + PregeneratedRandomization: o.PregeneratedRandomization, + } + + return generate[[]int64](ctx, r, "generateIntegerSequences", params) +} + +// GenerateDecimalFractionsOptions holds optional parameters for GenerateDecimalFractions. +type GenerateDecimalFractionsOptions struct { + // Replacement specifies whether the numbers are picked with + // replacement. nil (the default) behaves like true. + Replacement *bool + // PregeneratedRandomization selects historical randomness instead of + // fresh, on-the-fly randomness. nil (the default) uses fresh randomness. + PregeneratedRandomization *PregeneratedRandomization +} + type generateDecimalFractionsParams struct { baseParams - N int `json:"n"` - DecimalPlaces int `json:"decimalPlaces"` + N int `json:"n"` + DecimalPlaces int `json:"decimalPlaces"` + Replacement *bool `json:"replacement,omitempty"` + PregeneratedRandomization *PregeneratedRandomization `json:"pregeneratedRandomization,omitempty"` } // GenerateDecimalFractions generates n number of decimal fractions with decimalPlaces number of decimal places. -func (r *Random) GenerateDecimalFractions(ctx context.Context, n, decimalPlaces int) ([]float64, error) { +func (r *Random) GenerateDecimalFractions(ctx context.Context, n, decimalPlaces int, opts ...GenerateDecimalFractionsOptions) ([]float64, error) { if n < 1 || n > 10_000 { return nil, ErrParamRange } @@ -75,25 +239,40 @@ func (r *Random) GenerateDecimalFractions(ctx context.Context, n, decimalPlaces return nil, ErrParamRange } + o := resolveOptions(opts) + if err := validatePregeneratedRandomization(o.PregeneratedRandomization); err != nil { + return nil, err + } + params := generateDecimalFractionsParams{ - baseParams: baseParams{APIKey: r.apiKey}, - N: n, - DecimalPlaces: decimalPlaces, + baseParams: baseParams{APIKey: r.apiKey}, + N: n, + DecimalPlaces: decimalPlaces, + Replacement: o.Replacement, + PregeneratedRandomization: o.PregeneratedRandomization, } return generate[float64](ctx, r, "generateDecimalFractions", params) } +// GenerateGaussiansOptions holds optional parameters for GenerateGaussians. +type GenerateGaussiansOptions struct { + // PregeneratedRandomization selects historical randomness instead of + // fresh, on-the-fly randomness. nil (the default) uses fresh randomness. + PregeneratedRandomization *PregeneratedRandomization +} + type generateGaussiansParams struct { baseParams - N int `json:"n"` - Mean float64 `json:"mean"` - StandardDeviation float64 `json:"standardDeviation"` - SignificantDigits int `json:"significantDigits"` + N int `json:"n"` + Mean float64 `json:"mean"` + StandardDeviation float64 `json:"standardDeviation"` + SignificantDigits int `json:"significantDigits"` + PregeneratedRandomization *PregeneratedRandomization `json:"pregeneratedRandomization,omitempty"` } // GenerateGaussians generates true random numbers from a Gaussian distribution. -func (r *Random) GenerateGaussians(ctx context.Context, n int, mean, standardDeviation float64, significantDigits int) ([]float64, error) { +func (r *Random) GenerateGaussians(ctx context.Context, n int, mean, standardDeviation float64, significantDigits int, opts ...GenerateGaussiansOptions) ([]float64, error) { if n < 1 || n > 10_000 { return nil, ErrParamRange } @@ -107,26 +286,44 @@ func (r *Random) GenerateGaussians(ctx context.Context, n int, mean, standardDev return nil, ErrParamRange } + o := resolveOptions(opts) + if err := validatePregeneratedRandomization(o.PregeneratedRandomization); err != nil { + return nil, err + } + params := generateGaussiansParams{ - baseParams: baseParams{APIKey: r.apiKey}, - N: n, - Mean: mean, - StandardDeviation: standardDeviation, - SignificantDigits: significantDigits, + baseParams: baseParams{APIKey: r.apiKey}, + N: n, + Mean: mean, + StandardDeviation: standardDeviation, + SignificantDigits: significantDigits, + PregeneratedRandomization: o.PregeneratedRandomization, } return generate[float64](ctx, r, "generateGaussians", params) } +// GenerateStringsOptions holds optional parameters for GenerateStrings. +type GenerateStringsOptions struct { + // Replacement specifies whether the strings are picked with + // replacement. nil (the default) behaves like true. + Replacement *bool + // PregeneratedRandomization selects historical randomness instead of + // fresh, on-the-fly randomness. nil (the default) uses fresh randomness. + PregeneratedRandomization *PregeneratedRandomization +} + type generateStringsParams struct { baseParams - N int `json:"n"` - Length int `json:"length"` - Characters string `json:"characters"` + N int `json:"n"` + Length int `json:"length"` + Characters string `json:"characters"` + Replacement *bool `json:"replacement,omitempty"` + PregeneratedRandomization *PregeneratedRandomization `json:"pregeneratedRandomization,omitempty"` } // GenerateStrings generates n random strings with the given length composed from the characters. -func (r *Random) GenerateStrings(ctx context.Context, n, length int, characters string) ([]string, error) { +func (r *Random) GenerateStrings(ctx context.Context, n, length int, characters string, opts ...GenerateStringsOptions) ([]string, error) { if n < 1 || n > 10_000 { return nil, ErrParamRange } @@ -137,54 +334,101 @@ func (r *Random) GenerateStrings(ctx context.Context, n, length int, characters return nil, ErrParamRange } + o := resolveOptions(opts) + if err := validatePregeneratedRandomization(o.PregeneratedRandomization); err != nil { + return nil, err + } + params := generateStringsParams{ - baseParams: baseParams{APIKey: r.apiKey}, - N: n, - Length: length, - Characters: characters, + baseParams: baseParams{APIKey: r.apiKey}, + N: n, + Length: length, + Characters: characters, + Replacement: o.Replacement, + PregeneratedRandomization: o.PregeneratedRandomization, } return generate[string](ctx, r, "generateStrings", params) } +// GenerateUUIDsOptions holds optional parameters for GenerateUUIDs. +type GenerateUUIDsOptions struct { + // PregeneratedRandomization selects historical randomness instead of + // fresh, on-the-fly randomness. nil (the default) uses fresh randomness. + PregeneratedRandomization *PregeneratedRandomization +} + type generateUUIDsParams struct { baseParams - N int `json:"n"` + N int `json:"n"` + PregeneratedRandomization *PregeneratedRandomization `json:"pregeneratedRandomization,omitempty"` } // GenerateUUIDs generates n random version 4 Universally Unique Identifiers (see section 4.4 of RFC 4122) -func (r *Random) GenerateUUIDs(ctx context.Context, n int) ([]string, error) { +func (r *Random) GenerateUUIDs(ctx context.Context, n int, opts ...GenerateUUIDsOptions) ([]string, error) { if n < 1 || n > 1_000 { return nil, ErrParamRange } + o := resolveOptions(opts) + if err := validatePregeneratedRandomization(o.PregeneratedRandomization); err != nil { + return nil, err + } + params := generateUUIDsParams{ - baseParams: baseParams{APIKey: r.apiKey}, - N: n, + baseParams: baseParams{APIKey: r.apiKey}, + N: n, + PregeneratedRandomization: o.PregeneratedRandomization, } return generate[string](ctx, r, "generateUUIDs", params) } +// GenerateBlobsOptions holds optional parameters for GenerateBlobs. +type GenerateBlobsOptions struct { + // Format specifies the encoding used for the returned blobs: + // BlobFormatBase64 (the default) or BlobFormatHex. + Format string + // PregeneratedRandomization selects historical randomness instead of + // fresh, on-the-fly randomness. nil (the default) uses fresh randomness. + PregeneratedRandomization *PregeneratedRandomization +} + type generateBlobsParams struct { baseParams - N int `json:"n"` - Size int `json:"size"` + N int `json:"n"` + Size int `json:"size"` + Format string `json:"format,omitempty"` + PregeneratedRandomization *PregeneratedRandomization `json:"pregeneratedRandomization,omitempty"` } -// GenerateBlobs generates n random blobs of size. -func (r *Random) GenerateBlobs(ctx context.Context, n, size int) ([]string, error) { +// GenerateBlobs generates n random blobs of size (in bits, must be divisible by 8). +// The total size of all blobs requested (n*size) must not exceed 1,048,576 bits. +func (r *Random) GenerateBlobs(ctx context.Context, n, size int, opts ...GenerateBlobsOptions) ([]string, error) { if n < 1 || n > 100 { return nil, ErrParamRange } - if size < 1 || size > 1048576 || size%8 != 0 { + if size < 1 || size > 1_048_576 || size%8 != 0 { + return nil, ErrParamRange + } + if n*size > 1_048_576 { + return nil, ErrParamRange + } + + o := resolveOptions(opts) + if o.Format != "" && o.Format != BlobFormatBase64 && o.Format != BlobFormatHex { return nil, ErrParamRange } + if err := validatePregeneratedRandomization(o.PregeneratedRandomization); err != nil { + return nil, err + } params := generateBlobsParams{ - baseParams: baseParams{APIKey: r.apiKey}, - N: n, - Size: size, + baseParams: baseParams{APIKey: r.apiKey}, + N: n, + Size: size, + Format: o.Format, + PregeneratedRandomization: o.PregeneratedRandomization, } return generate[string](ctx, r, "generateBlobs", params) diff --git a/basic_test.go b/basic_test.go index 429b020..a5db351 100644 --- a/basic_test.go +++ b/basic_test.go @@ -56,6 +56,153 @@ func TestGenerateIntegers(t *testing.T) { t.Errorf("method = %q, want generateIntegers", gotMethod) } }) + + t.Run("options are sent", func(t *testing.T) { + var gotParams map[string]any + random := newTestRandom(t, func(req *http.Request) (*http.Response, error) { + gotParams, _ = decodeRequestBody(t, req)["params"].(map[string]any) + return jsonResponse(http.StatusOK, `{ + "jsonrpc": "2.0", + "result": {"random": {"data": [7]}, "bitsLeft": 1, "requestsLeft": 1}, + "id": "1" + }`), nil + }) + + _, err := random.GenerateIntegers(context.Background(), 1, 0, 10, randomorg.GenerateIntegersOptions{ + Replacement: randomorg.Bool(false), + PregeneratedRandomization: randomorg.PregeneratedRandomizationByDate("2021-01-01"), + }) + if err != nil { + t.Fatalf("GenerateIntegers() error = %v", err) + } + + if gotParams["replacement"] != false { + t.Errorf("params[replacement] = %v, want false", gotParams["replacement"]) + } + pr, ok := gotParams["pregeneratedRandomization"].(map[string]any) + if !ok { + t.Fatalf("params[pregeneratedRandomization] missing or wrong type: %v", gotParams["pregeneratedRandomization"]) + } + if pr["date"] != "2021-01-01" { + t.Errorf("params[pregeneratedRandomization][date] = %v, want 2021-01-01", pr["date"]) + } + }) + + t.Run("omitted options are not sent", func(t *testing.T) { + var gotParams map[string]any + random := newTestRandom(t, func(req *http.Request) (*http.Response, error) { + gotParams, _ = decodeRequestBody(t, req)["params"].(map[string]any) + return jsonResponse(http.StatusOK, `{ + "jsonrpc": "2.0", + "result": {"random": {"data": [7]}, "bitsLeft": 1, "requestsLeft": 1}, + "id": "1" + }`), nil + }) + + if _, err := random.GenerateIntegers(context.Background(), 1, 0, 10); err != nil { + t.Fatalf("GenerateIntegers() error = %v", err) + } + + if _, ok := gotParams["replacement"]; ok { + t.Errorf("params[replacement] present = %v, want absent", gotParams["replacement"]) + } + if _, ok := gotParams["pregeneratedRandomization"]; ok { + t.Errorf("params[pregeneratedRandomization] present = %v, want absent", gotParams["pregeneratedRandomization"]) + } + }) + + t.Run("pregeneratedRandomization id too long", func(t *testing.T) { + random := newTestRandom(t, failOnRequest(t)) + _, err := random.GenerateIntegers(context.Background(), 1, 0, 10, randomorg.GenerateIntegersOptions{ + PregeneratedRandomization: randomorg.PregeneratedRandomizationByID(strings.Repeat("a", 65)), + }) + if !errors.Is(err, randomorg.ErrParamRange) { + t.Fatalf("err = %v, want %v", err, randomorg.ErrParamRange) + } + }) +} + +func TestGenerateIntegerSequences(t *testing.T) { + t.Run("param validation", func(t *testing.T) { + cases := []struct { + name string + n int + length []int + min, max []int64 + }{ + {"n too small", 0, []int{1}, []int64{0}, []int64{10}}, + {"n too large", 1001, make([]int, 1001), make([]int64, 1001), make([]int64, 1001)}, + {"length slice wrong size", 2, []int{1}, []int64{0, 0}, []int64{10, 10}}, + {"min slice wrong size", 2, []int{1, 1}, []int64{0}, []int64{10, 10}}, + {"max slice wrong size", 2, []int{1, 1}, []int64{0, 0}, []int64{10}}, + {"length element too small", 1, []int{0}, []int64{0}, []int64{10}}, + {"length element too large", 1, []int{10_001}, []int64{0}, []int64{10}}, + {"min element too small", 1, []int{1}, []int64{-1e9 - 1}, []int64{10}}, + {"max element too large", 1, []int{1}, []int64{0}, []int64{1e9 + 1}}, + {"length sum too large", 2, []int{6_000, 6_000}, []int64{0, 0}, []int64{10, 10}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + random := newTestRandom(t, failOnRequest(t)) + _, err := random.GenerateIntegerSequences(context.Background(), tc.n, tc.length, tc.min, tc.max) + if !errors.Is(err, randomorg.ErrParamRange) { + t.Fatalf("err = %v, want %v", err, randomorg.ErrParamRange) + } + }) + } + }) + + t.Run("mismatched replacement length", func(t *testing.T) { + random := newTestRandom(t, failOnRequest(t)) + _, err := random.GenerateIntegerSequences(context.Background(), 2, []int{1, 1}, []int64{0, 0}, []int64{10, 10}, randomorg.GenerateIntegerSequencesOptions{ + Replacement: []bool{true}, + }) + if !errors.Is(err, randomorg.ErrParamRange) { + t.Fatalf("err = %v, want %v", err, randomorg.ErrParamRange) + } + }) + + // Example from https://api.random.org/json-rpc/4/basic#generateIntegerSequences + t.Run("generates values (docs example)", func(t *testing.T) { + var gotReq map[string]any + random := newTestRandom(t, func(req *http.Request) (*http.Response, error) { + gotReq = decodeRequestBody(t, req) + return jsonResponse(http.StatusOK, `{ + "jsonrpc": "2.0", + "result": { + "random": { + "data": [[28, 31, 41, 65, 42], [14]], + "completionTime": "2018-01-29 17:34:46Z" + }, + "bitsUsed": 36, + "bitsLeft": 833949, + "requestsLeft": 199598, + "advisoryDelay": 200 + }, + "id": "45673" + }`), nil + }) + + got, err := random.GenerateIntegerSequences(context.Background(), 2, []int{5, 1}, []int64{1, 1}, []int64{69, 26}, randomorg.GenerateIntegerSequencesOptions{ + Replacement: []bool{false, false}, + }) + if err != nil { + t.Fatalf("GenerateIntegerSequences() error = %v", err) + } + want := [][]int64{{28, 31, 41, 65, 42}, {14}} + if len(got) != len(want) { + t.Fatalf("GenerateIntegerSequences() = %v, want %v", got, want) + } + for i := range want { + if !slices.Equal(got[i], want[i]) { + t.Fatalf("GenerateIntegerSequences()[%d] = %v, want %v", i, got[i], want[i]) + } + } + + if gotReq["method"] != "generateIntegerSequences" { + t.Errorf("method = %q, want generateIntegerSequences", gotReq["method"]) + } + }) } func TestGenerateDecimalFractions(t *testing.T) { @@ -103,6 +250,28 @@ func TestGenerateDecimalFractions(t *testing.T) { t.Errorf("method = %q, want generateDecimalFractions", gotMethod) } }) + + t.Run("replacement option is sent", func(t *testing.T) { + var gotParams map[string]any + random := newTestRandom(t, func(req *http.Request) (*http.Response, error) { + gotParams, _ = decodeRequestBody(t, req)["params"].(map[string]any) + return jsonResponse(http.StatusOK, `{ + "jsonrpc": "2.0", + "result": {"random": {"data": [0.5]}, "bitsLeft": 1, "requestsLeft": 1}, + "id": "1" + }`), nil + }) + + _, err := random.GenerateDecimalFractions(context.Background(), 1, 2, randomorg.GenerateDecimalFractionsOptions{ + Replacement: randomorg.Bool(false), + }) + if err != nil { + t.Fatalf("GenerateDecimalFractions() error = %v", err) + } + if gotParams["replacement"] != false { + t.Errorf("params[replacement] = %v, want false", gotParams["replacement"]) + } + }) } func TestGenerateGaussians(t *testing.T) { @@ -155,6 +324,32 @@ func TestGenerateGaussians(t *testing.T) { t.Errorf("method = %q, want generateGaussians", gotMethod) } }) + + t.Run("pregeneratedRandomization option is sent", func(t *testing.T) { + var gotParams map[string]any + random := newTestRandom(t, func(req *http.Request) (*http.Response, error) { + gotParams, _ = decodeRequestBody(t, req)["params"].(map[string]any) + return jsonResponse(http.StatusOK, `{ + "jsonrpc": "2.0", + "result": {"random": {"data": [0.1]}, "bitsLeft": 1, "requestsLeft": 1}, + "id": "1" + }`), nil + }) + + _, err := random.GenerateGaussians(context.Background(), 1, 0, 1, 4, randomorg.GenerateGaussiansOptions{ + PregeneratedRandomization: randomorg.PregeneratedRandomizationByID("my-persistent-id"), + }) + if err != nil { + t.Fatalf("GenerateGaussians() error = %v", err) + } + pr, ok := gotParams["pregeneratedRandomization"].(map[string]any) + if !ok { + t.Fatalf("params[pregeneratedRandomization] missing or wrong type: %v", gotParams["pregeneratedRandomization"]) + } + if pr["id"] != "my-persistent-id" { + t.Errorf("params[pregeneratedRandomization][id] = %v, want my-persistent-id", pr["id"]) + } + }) } func TestGenerateStrings(t *testing.T) { @@ -204,6 +399,28 @@ func TestGenerateStrings(t *testing.T) { t.Errorf("method = %q, want generateStrings", gotMethod) } }) + + t.Run("replacement option is sent", func(t *testing.T) { + var gotParams map[string]any + random := newTestRandom(t, func(req *http.Request) (*http.Response, error) { + gotParams, _ = decodeRequestBody(t, req)["params"].(map[string]any) + return jsonResponse(http.StatusOK, `{ + "jsonrpc": "2.0", + "result": {"random": {"data": ["abc"]}, "bitsLeft": 1, "requestsLeft": 1}, + "id": "1" + }`), nil + }) + + _, err := random.GenerateStrings(context.Background(), 1, 3, "abc", randomorg.GenerateStringsOptions{ + Replacement: randomorg.Bool(false), + }) + if err != nil { + t.Fatalf("GenerateStrings() error = %v", err) + } + if gotParams["replacement"] != false { + t.Errorf("params[replacement] = %v, want false", gotParams["replacement"]) + } + }) } func TestGenerateUUIDs(t *testing.T) { @@ -248,6 +465,32 @@ func TestGenerateUUIDs(t *testing.T) { t.Errorf("method = %q, want generateUUIDs", gotMethod) } }) + + t.Run("pregeneratedRandomization option is sent", func(t *testing.T) { + var gotParams map[string]any + random := newTestRandom(t, func(req *http.Request) (*http.Response, error) { + gotParams, _ = decodeRequestBody(t, req)["params"].(map[string]any) + return jsonResponse(http.StatusOK, `{ + "jsonrpc": "2.0", + "result": {"random": {"data": ["11111111-1111-4111-8111-111111111111"]}, "bitsLeft": 1, "requestsLeft": 1}, + "id": "1" + }`), nil + }) + + _, err := random.GenerateUUIDs(context.Background(), 1, randomorg.GenerateUUIDsOptions{ + PregeneratedRandomization: randomorg.PregeneratedRandomizationByDate("2020-06-15"), + }) + if err != nil { + t.Fatalf("GenerateUUIDs() error = %v", err) + } + pr, ok := gotParams["pregeneratedRandomization"].(map[string]any) + if !ok { + t.Fatalf("params[pregeneratedRandomization] missing or wrong type: %v", gotParams["pregeneratedRandomization"]) + } + if pr["date"] != "2020-06-15" { + t.Errorf("params[pregeneratedRandomization][date] = %v, want 2020-06-15", pr["date"]) + } + }) } func TestGenerateBlobs(t *testing.T) { @@ -261,6 +504,7 @@ func TestGenerateBlobs(t *testing.T) { {"size too small", 1, 0}, {"size too large", 1, 1048584}, {"size not multiple of 8", 1, 7}, + {"aggregate size too large", 2, 1_048_576}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { @@ -295,4 +539,32 @@ func TestGenerateBlobs(t *testing.T) { t.Errorf("method = %q, want generateBlobs", gotMethod) } }) + + t.Run("format option is sent", func(t *testing.T) { + var gotParams map[string]any + random := newTestRandom(t, func(req *http.Request) (*http.Response, error) { + gotParams, _ = decodeRequestBody(t, req)["params"].(map[string]any) + return jsonResponse(http.StatusOK, `{ + "jsonrpc": "2.0", + "result": {"random": {"data": ["deadbeef"]}, "bitsLeft": 1, "requestsLeft": 1}, + "id": "1" + }`), nil + }) + + _, err := random.GenerateBlobs(context.Background(), 1, 8, randomorg.GenerateBlobsOptions{Format: randomorg.BlobFormatHex}) + if err != nil { + t.Fatalf("GenerateBlobs() error = %v", err) + } + if gotParams["format"] != "hex" { + t.Errorf("params[format] = %v, want hex", gotParams["format"]) + } + }) + + t.Run("invalid format is rejected", func(t *testing.T) { + random := newTestRandom(t, failOnRequest(t)) + _, err := random.GenerateBlobs(context.Background(), 1, 8, randomorg.GenerateBlobsOptions{Format: "bogus"}) + if !errors.Is(err, randomorg.ErrParamRange) { + t.Fatalf("err = %v, want %v", err, randomorg.ErrParamRange) + } + }) } diff --git a/example_test.go b/example_test.go index 2797995..0e4ce8a 100644 --- a/example_test.go +++ b/example_test.go @@ -31,3 +31,304 @@ func ExampleRandom_GenerateIntegers() { value, _ := random.GenerateIntegers(context.Background(), 1, 0, 10) fmt.Printf("Random value: %v\n", value) } + +// Generate 5 unique numbers from 1-6, like drawing raffle tickets without +// putting them back. +func ExampleRandom_GenerateIntegers_withoutReplacement() { + random, err := randomorg.NewRandom(apiKey, &http.Client{}) + if err != nil { + panic(err) + } + values, err := random.GenerateIntegers(context.Background(), 5, 1, 6, randomorg.GenerateIntegersOptions{ + Replacement: randomorg.Bool(false), + }) + if err != nil { + panic(err) + } + fmt.Printf("Random values: %v\n", values) +} + +// Generate two integer sequences: 5 unique numbers from 1-69 and 1 number +// from 1-26, as in a lottery draw with a separate bonus ball. +func ExampleRandom_GenerateIntegerSequences() { + random, err := randomorg.NewRandom(apiKey, &http.Client{}) + if err != nil { + panic(err) + } + sequences, err := random.GenerateIntegerSequences( + context.Background(), + 2, + []int{5, 1}, + []int64{1, 1}, + []int64{69, 26}, + randomorg.GenerateIntegerSequencesOptions{Replacement: []bool{false, false}}, + ) + if err != nil { + panic(err) + } + fmt.Printf("Main numbers: %v, bonus number: %v\n", sequences[0], sequences[1]) +} + +// Generate 10 decimal fractions in [0,1) with 8 decimal places. +func ExampleRandom_GenerateDecimalFractions() { + random, err := randomorg.NewRandom(apiKey, &http.Client{}) + if err != nil { + panic(err) + } + values, err := random.GenerateDecimalFractions(context.Background(), 10, 8) + if err != nil { + panic(err) + } + fmt.Printf("Random values: %v\n", values) +} + +// Generate 4 numbers from a Gaussian distribution with mean 0 and standard +// deviation 1, accurate to 8 significant digits. +func ExampleRandom_GenerateGaussians() { + random, err := randomorg.NewRandom(apiKey, &http.Client{}) + if err != nil { + panic(err) + } + values, err := random.GenerateGaussians(context.Background(), 4, 0, 1, 8) + if err != nil { + panic(err) + } + fmt.Printf("Random values: %v\n", values) +} + +// Generate 8 random lowercase strings of length 10. +func ExampleRandom_GenerateStrings() { + random, err := randomorg.NewRandom(apiKey, &http.Client{}) + if err != nil { + panic(err) + } + values, err := random.GenerateStrings(context.Background(), 8, 10, "abcdefghijklmnopqrstuvwxyz") + if err != nil { + panic(err) + } + fmt.Printf("Random values: %v\n", values) +} + +// Generate one random version 4 UUID. +func ExampleRandom_GenerateUUIDs() { + random, err := randomorg.NewRandom(apiKey, &http.Client{}) + if err != nil { + panic(err) + } + values, err := random.GenerateUUIDs(context.Background(), 1) + if err != nil { + panic(err) + } + fmt.Printf("Random values: %v\n", values) +} + +// Generate one 1024-bit random blob, base64-encoded. +func ExampleRandom_GenerateBlobs() { + random, err := randomorg.NewRandom(apiKey, &http.Client{}) + if err != nil { + panic(err) + } + values, err := random.GenerateBlobs(context.Background(), 1, 1024) + if err != nil { + panic(err) + } + fmt.Printf("Random values: %v\n", values) +} + +// GetUsage reports how much of the API key's quota remains. +func ExampleRandom_GetUsage() { + random, err := randomorg.NewRandom(apiKey, &http.Client{}) + if err != nil { + panic(err) + } + usage, err := random.GetUsage(context.Background()) + if err != nil { + panic(err) + } + fmt.Printf("Bits left: %d\n", usage.BitsLeft) +} + +// Generate three digitally signed dice rolls, then verify the returned +// signature to confirm the values are authentic RANDOM.ORG output. +func ExampleRandom_GenerateSignedIntegers() { + random, err := randomorg.NewRandom(apiKey, &http.Client{}) + if err != nil { + panic(err) + } + + result, err := random.GenerateSignedIntegers(context.Background(), 3, 1, 6) + if err != nil { + panic(err) + } + fmt.Printf("Dice roll: %v\n", result.Data) + + authentic, err := random.VerifySignature(context.Background(), result.Random, result.Signature) + if err != nil { + panic(err) + } + fmt.Printf("Authentic: %v\n", authentic) +} + +// The Signed API counterpart of GenerateIntegerSequences: the response also +// carries a signature proving the values were generated by RANDOM.ORG. +func ExampleRandom_GenerateSignedIntegerSequences() { + random, err := randomorg.NewRandom(apiKey, &http.Client{}) + if err != nil { + panic(err) + } + result, err := random.GenerateSignedIntegerSequences( + context.Background(), + 2, + []int{5, 1}, + []int64{1, 1}, + []int64{69, 26}, + randomorg.GenerateSignedIntegerSequencesOptions{Replacement: []bool{false, false}}, + ) + if err != nil { + panic(err) + } + fmt.Printf("Main numbers: %v, bonus number: %v\n", result.Data[0], result.Data[1]) +} + +func ExampleRandom_GenerateSignedDecimalFractions() { + random, err := randomorg.NewRandom(apiKey, &http.Client{}) + if err != nil { + panic(err) + } + result, err := random.GenerateSignedDecimalFractions(context.Background(), 10, 8) + if err != nil { + panic(err) + } + fmt.Printf("Random values: %v\n", result.Data) +} + +func ExampleRandom_GenerateSignedGaussians() { + random, err := randomorg.NewRandom(apiKey, &http.Client{}) + if err != nil { + panic(err) + } + result, err := random.GenerateSignedGaussians(context.Background(), 4, 0, 1, 8) + if err != nil { + panic(err) + } + fmt.Printf("Random values: %v\n", result.Data) +} + +func ExampleRandom_GenerateSignedStrings() { + random, err := randomorg.NewRandom(apiKey, &http.Client{}) + if err != nil { + panic(err) + } + result, err := random.GenerateSignedStrings(context.Background(), 8, 10, "abcdefghijklmnopqrstuvwxyz") + if err != nil { + panic(err) + } + fmt.Printf("Random values: %v\n", result.Data) +} + +func ExampleRandom_GenerateSignedUUIDs() { + random, err := randomorg.NewRandom(apiKey, &http.Client{}) + if err != nil { + panic(err) + } + result, err := random.GenerateSignedUUIDs(context.Background(), 1) + if err != nil { + panic(err) + } + fmt.Printf("Random values: %v\n", result.Data) +} + +func ExampleRandom_GenerateSignedBlobs() { + random, err := randomorg.NewRandom(apiKey, &http.Client{}) + if err != nil { + panic(err) + } + result, err := random.GenerateSignedBlobs(context.Background(), 1, 1024) + if err != nil { + panic(err) + } + fmt.Printf("Random values: %v\n", result.Data) +} + +// GetResult retrieves a previously generated Signed API result by its +// serial number — useful if, say, a network failure prevented the original +// response from being delivered. GetResult is a package-level generic +// function rather than a method because Go methods can't introduce their +// own type parameters. +func ExampleGetResult() { + random, err := randomorg.NewRandom(apiKey, &http.Client{}) + if err != nil { + panic(err) + } + result, err := randomorg.GetResult[int64](context.Background(), random, 6116) + if err != nil { + panic(err) + } + fmt.Printf("Random values: %v\n", result.Data) +} + +// Create a ticket, spend it on a digitally signed value, and then retrieve +// the full audit trail for that ticket via GetTicket. +func ExampleRandom_CreateTickets() { + random, err := randomorg.NewRandom(apiKey, &http.Client{}) + if err != nil { + panic(err) + } + + tickets, err := random.CreateTickets(context.Background(), 1, true) + if err != nil { + panic(err) + } + + result, err := random.GenerateSignedIntegers(context.Background(), 1, 1, 6, randomorg.GenerateSignedIntegersOptions{ + SignedCommonOptions: randomorg.SignedCommonOptions{TicketID: tickets[0].TicketID}, + }) + if err != nil { + panic(err) + } + fmt.Printf("Value: %v\n", result.Data) + + ticket, err := random.GetTicket(context.Background(), tickets[0].TicketID) + if err != nil { + panic(err) + } + if ticket.Result != nil { + decoded, err := randomorg.DecodeTicketResult[int64](ticket.Result) + if err != nil { + panic(err) + } + fmt.Printf("Audited value: %v\n", decoded.Data) + } +} + +// RevealTickets exposes the random values behind a ticket that was created +// with showResult set to false, after the ticket has been used. +func ExampleRandom_RevealTickets() { + random, err := randomorg.NewRandom(apiKey, &http.Client{}) + if err != nil { + panic(err) + } + tickets, err := random.CreateTickets(context.Background(), 1, false) + if err != nil { + panic(err) + } + revealed, err := random.RevealTickets(context.Background(), tickets[0].TicketID) + if err != nil { + panic(err) + } + fmt.Printf("Revealed %d ticket(s)\n", revealed) +} + +// ListTickets enumerates all of an API key's singleton tickets, i.e. those +// that are neither the head nor the tail of a longer chain. +func ExampleRandom_ListTickets() { + random, err := randomorg.NewRandom(apiKey, &http.Client{}) + if err != nil { + panic(err) + } + tickets, err := random.ListTickets(context.Background(), randomorg.TicketTypeSingleton) + if err != nil { + panic(err) + } + fmt.Printf("%d singleton ticket(s)\n", len(tickets)) +} diff --git a/randomorg.go b/randomorg.go index 5c7d18d..c4635d2 100644 --- a/randomorg.go +++ b/randomorg.go @@ -12,6 +12,7 @@ import ( "fmt" "io" "net/http" + "strings" "sync" "time" @@ -78,6 +79,31 @@ type baseParams struct { APIKey string `json:"apiKey"` } +// resolveOptions returns the first element of opts, or the zero value of T +// if opts is empty. It backs the "opts ...MethodOptions" pattern used to add +// optional parameters to methods without breaking existing call sites. +func resolveOptions[T any](opts []T) T { + var o T + if len(opts) > 0 { + o = opts[0] + } + return o +} + +// Bool returns a pointer to b. It is a convenience for setting Options +// fields that distinguish "not specified" (nil) from an explicit false, +// such as Replacement. +func Bool(b bool) *bool { + return &b +} + +// parseAPITime parses a RANDOM.ORG timestamp such as "2013-02-20 17:53:40Z" +// (a space rather than the RFC 3339 "T" separating date and time) into a +// time.Time. +func parseAPITime(s string) (time.Time, error) { + return time.Parse(creationTimeLayout, strings.Replace(s, " ", "T", 1)) +} + // jsonRPCRequest is the envelope for every Random.org JSON-RPC 2.0 request. type jsonRPCRequest struct { JSONRPC string `json:"jsonrpc"` diff --git a/signed.go b/signed.go new file mode 100644 index 0000000..6f17561 --- /dev/null +++ b/signed.go @@ -0,0 +1,689 @@ +package randomorg + +import ( + "context" + "encoding/json" + "time" +) + +// Signed commands +// see https://api.random.org/json-rpc/4/signed + +// License describes the terms under which the random values in a +// SignedResult may be used, as selected by the owner of the API key that +// generated them. +type License struct { + Type string `json:"type"` + Text string `json:"text"` + InfoURL *string `json:"infoUrl"` +} + +// LicenseData carries information required by certain license types (e.g. +// Flexible Gambling), specifying the Maximum Payout Value (MPV) for the +// game round or lottery draw whose outcome the random values decide. +type LicenseData struct { + MaxPayoutValue MaxPayoutValue `json:"maxPayoutValue"` +} + +// MaxPayoutValue is an amount in a RANDOM.ORG-supported currency (USD, EUR, +// GBP, CAD, AUD, BTC, ETH), identified by an ISO 4217-style currency code. +type MaxPayoutValue struct { + Currency string `json:"currency"` + Amount float64 `json:"amount"` +} + +// TicketData identifies the ticket chain position consumed by a signed +// generate call. It is present in a SignedResult only when the request +// specified a TicketID. +type TicketData struct { + TicketID string `json:"ticketId"` + PreviousTicketID *string `json:"previousTicketId"` + NextTicketID *string `json:"nextTicketId"` +} + +// SignedResult is the response from a Signed API generate method or +// GetResult: the random values plus everything needed to prove they were +// generated by RANDOM.ORG. +type SignedResult[T any] struct { + Method string + HashedAPIKey string + Data []T + License License + LicenseData json.RawMessage + UserData json.RawMessage + TicketData *TicketData + CompletionTime time.Time + SerialNumber int + + // Random is the raw, unmodified "random" object exactly as returned by + // RANDOM.ORG. Pass it together with Signature to VerifySignature — + // re-encoding the typed fields above is not guaranteed to reproduce the + // exact bytes RANDOM.ORG signed. + Random json.RawMessage + Signature string + + Cost float64 + BitsUsed int + BitsLeft int + RequestsLeft int + AdvisoryDelay int +} + +// signedResultEnvelope is the JSON-RPC result payload of every Signed API +// generate method, and of getResult. +type signedResultEnvelope struct { + Random json.RawMessage `json:"random"` + Signature string `json:"signature"` + Cost float64 `json:"cost"` + BitsUsed int `json:"bitsUsed"` + BitsLeft int `json:"bitsLeft"` + RequestsLeft int `json:"requestsLeft"` + AdvisoryDelay int `json:"advisoryDelay"` +} + +// signedRandomEnvelope is the shape of the "random" object nested in +// signedResultEnvelope. It is method-agnostic: fields that only exist for +// some methods (e.g. min/max, characters) are decoded separately by callers +// that need them, since SignedResult only surfaces the fields common to +// every Signed API method. +type signedRandomEnvelope[T any] struct { + Method string `json:"method"` + HashedAPIKey string `json:"hashedApiKey"` + Data []T `json:"data"` + License License `json:"license"` + LicenseData json.RawMessage `json:"licenseData"` + UserData json.RawMessage `json:"userData"` + TicketData *TicketData `json:"ticketData"` + CompletionTime string `json:"completionTime"` + SerialNumber int `json:"serialNumber"` +} + +// decodeSignedResult unpacks a signedResultEnvelope's raw "random" object +// into a typed SignedResult[T]. It is shared by every GenerateSigned* +// method, GetResult, and DecodeTicketResult. +func decodeSignedResult[T any](env signedResultEnvelope) (SignedResult[T], error) { + if env.Random == nil { + return SignedResult[T]{}, ErrJSONFormat + } + + var randomEnv signedRandomEnvelope[T] + if err := json.Unmarshal(env.Random, &randomEnv); err != nil { + return SignedResult[T]{}, err + } + if randomEnv.Data == nil { + return SignedResult[T]{}, ErrJSONFormat + } + + completionTime, err := parseAPITime(randomEnv.CompletionTime) + if err != nil { + return SignedResult[T]{}, err + } + + return SignedResult[T]{ + Method: randomEnv.Method, + HashedAPIKey: randomEnv.HashedAPIKey, + Data: randomEnv.Data, + License: randomEnv.License, + LicenseData: randomEnv.LicenseData, + UserData: randomEnv.UserData, + TicketData: randomEnv.TicketData, + CompletionTime: completionTime, + SerialNumber: randomEnv.SerialNumber, + Random: env.Random, + Signature: env.Signature, + Cost: env.Cost, + BitsUsed: env.BitsUsed, + BitsLeft: env.BitsLeft, + RequestsLeft: env.RequestsLeft, + AdvisoryDelay: env.AdvisoryDelay, + }, nil +} + +// signedGenerate invokes method, decodes its signed result into a +// SignedResult[T], and merges the response's bitsLeft/requestsLeft into the +// client's usage cache, mirroring generate's behavior for the Basic API. +func signedGenerate[T any](ctx context.Context, r *Random, method string, params any) (SignedResult[T], error) { + env, err := invokeRequest[signedResultEnvelope](ctx, r, method, params) + if err != nil { + return SignedResult[T]{}, err + } + + result, err := decodeSignedResult[T](env) + if err != nil { + return SignedResult[T]{}, err + } + + bitsLeft, requestsLeft := env.BitsLeft, env.RequestsLeft + r.mergeUsage(usageFields{BitsLeft: &bitsLeft, RequestsLeft: &requestsLeft}) + + return result, nil +} + +// SignedCommonOptions holds the optional parameters shared by every +// GenerateSigned* method. +type SignedCommonOptions struct { + // PregeneratedRandomization selects historical randomness instead of + // fresh, on-the-fly randomness. nil (the default) uses fresh randomness. + PregeneratedRandomization *PregeneratedRandomization + // LicenseData carries data required by some license types (e.g. the + // Flexible Gambling license's maximum payout value); required for such + // license types. nil (the default) omits it. + LicenseData *LicenseData + // UserData is included, unmodified, in the signed response alongside + // the random data. Its JSON-encoded form must not exceed 1,000 + // characters. nil (the default) omits it. + UserData any + // TicketID, if set, consumes a ticket obtained from CreateTickets: + // RANDOM.ORG records that the ticket was used to generate these values. + // Each ticket can only be used once. + TicketID string +} + +// validateSignedCommonOptions validates the fields of SignedCommonOptions +// that can be checked client-side. +func validateSignedCommonOptions(o SignedCommonOptions) error { + if err := validatePregeneratedRandomization(o.PregeneratedRandomization); err != nil { + return err + } + if o.UserData != nil { + encoded, err := json.Marshal(o.UserData) + if err != nil { + return err + } + if len(encoded) > 1_000 { + return ErrParamRange + } + } + return nil +} + +// GenerateSignedIntegersOptions holds optional parameters for +// GenerateSignedIntegers and GenerateSignedIntegersWithBase. +type GenerateSignedIntegersOptions struct { + // Replacement specifies whether the numbers are picked with + // replacement. nil (the default) behaves like true. + Replacement *bool + SignedCommonOptions +} + +type generateSignedIntegersParams struct { + baseParams + N int `json:"n"` + Min int64 `json:"min"` + Max int64 `json:"max"` + Replacement *bool `json:"replacement,omitempty"` + Base int `json:"base,omitempty"` + PregeneratedRandomization *PregeneratedRandomization `json:"pregeneratedRandomization,omitempty"` + LicenseData *LicenseData `json:"licenseData,omitempty"` + UserData any `json:"userData,omitempty"` + TicketID string `json:"ticketId,omitempty"` +} + +// GenerateSignedIntegers generates n number of digitally signed random +// integers in the range from min to max. Unlike GenerateIntegers, the +// result includes a Signature that can be used to prove the values were +// generated by RANDOM.ORG; see VerifySignature. +func (r *Random) GenerateSignedIntegers(ctx context.Context, n int, min, max int64, opts ...GenerateSignedIntegersOptions) (SignedResult[int64], error) { + if n < 1 || n > 10_000 { + return SignedResult[int64]{}, ErrParamRange + } + if min < -1_000_000_000 || min > 1_000_000_000 || max < -1_000_000_000 || max > 1_000_000_000 { + return SignedResult[int64]{}, ErrParamRange + } + + o := resolveOptions(opts) + if err := validateSignedCommonOptions(o.SignedCommonOptions); err != nil { + return SignedResult[int64]{}, err + } + + params := generateSignedIntegersParams{ + baseParams: baseParams{APIKey: r.apiKey}, + N: n, + Min: min, + Max: max, + Replacement: o.Replacement, + PregeneratedRandomization: o.PregeneratedRandomization, + LicenseData: o.LicenseData, + UserData: o.UserData, + TicketID: o.TicketID, + } + + return signedGenerate[int64](ctx, r, "generateSignedIntegers", params) +} + +// GenerateSignedIntegersWithBase is GenerateSignedIntegers with the numbers +// displayed (and signed) in a non-decimal base: 2, 8 or 16. Because the +// server pads and formats the values as strings in these bases, the result +// carries them as strings rather than int64s. +func (r *Random) GenerateSignedIntegersWithBase(ctx context.Context, n int, min, max int64, base int, opts ...GenerateSignedIntegersOptions) (SignedResult[string], error) { + if n < 1 || n > 10_000 { + return SignedResult[string]{}, ErrParamRange + } + if min < -1_000_000_000 || min > 1_000_000_000 || max < -1_000_000_000 || max > 1_000_000_000 { + return SignedResult[string]{}, ErrParamRange + } + if base != 2 && base != 8 && base != 16 { + return SignedResult[string]{}, ErrParamRange + } + + o := resolveOptions(opts) + if err := validateSignedCommonOptions(o.SignedCommonOptions); err != nil { + return SignedResult[string]{}, err + } + + params := generateSignedIntegersParams{ + baseParams: baseParams{APIKey: r.apiKey}, + N: n, + Min: min, + Max: max, + Replacement: o.Replacement, + Base: base, + PregeneratedRandomization: o.PregeneratedRandomization, + LicenseData: o.LicenseData, + UserData: o.UserData, + TicketID: o.TicketID, + } + + return signedGenerate[string](ctx, r, "generateSignedIntegers", params) +} + +// GenerateSignedIntegerSequencesOptions holds optional parameters for +// GenerateSignedIntegerSequences and GenerateSignedIntegerSequencesWithBase. +type GenerateSignedIntegerSequencesOptions struct { + // Replacement specifies, per sequence, whether its numbers are picked + // with replacement. If non-nil, it must have exactly n elements. nil + // (the default) behaves as if every sequence used replacement. + Replacement []bool + SignedCommonOptions +} + +type generateSignedIntegerSequencesParams struct { + baseParams + N int `json:"n"` + Length []int `json:"length"` + Min []int64 `json:"min"` + Max []int64 `json:"max"` + Replacement []bool `json:"replacement,omitempty"` + Base []int `json:"base,omitempty"` + PregeneratedRandomization *PregeneratedRandomization `json:"pregeneratedRandomization,omitempty"` + LicenseData *LicenseData `json:"licenseData,omitempty"` + UserData any `json:"userData,omitempty"` + TicketID string `json:"ticketId,omitempty"` +} + +// GenerateSignedIntegerSequences is the Signed API counterpart of +// GenerateIntegerSequences: it generates n digitally signed sequences, +// where sequence i has length[i] elements drawn from [min[i], max[i]]. +func (r *Random) GenerateSignedIntegerSequences(ctx context.Context, n int, length []int, min, max []int64, opts ...GenerateSignedIntegerSequencesOptions) (SignedResult[[]int64], error) { + if err := validateSequenceParams(n, length, min, max); err != nil { + return SignedResult[[]int64]{}, err + } + + o := resolveOptions(opts) + if o.Replacement != nil && len(o.Replacement) != n { + return SignedResult[[]int64]{}, ErrParamRange + } + if err := validateSignedCommonOptions(o.SignedCommonOptions); err != nil { + return SignedResult[[]int64]{}, err + } + + params := generateSignedIntegerSequencesParams{ + baseParams: baseParams{APIKey: r.apiKey}, + N: n, + Length: length, + Min: min, + Max: max, + Replacement: o.Replacement, + PregeneratedRandomization: o.PregeneratedRandomization, + LicenseData: o.LicenseData, + UserData: o.UserData, + TicketID: o.TicketID, + } + + return signedGenerate[[]int64](ctx, r, "generateSignedIntegerSequences", params) +} + +// GenerateSignedIntegerSequencesWithBase is GenerateSignedIntegerSequences +// with each sequence displayed (and signed) in a non-decimal base: base +// must have exactly n elements, each 2, 8 or 16. +func (r *Random) GenerateSignedIntegerSequencesWithBase(ctx context.Context, n int, length []int, min, max []int64, base []int, opts ...GenerateSignedIntegerSequencesOptions) (SignedResult[[]string], error) { + if err := validateSequenceParams(n, length, min, max); err != nil { + return SignedResult[[]string]{}, err + } + if len(base) != n { + return SignedResult[[]string]{}, ErrParamRange + } + for _, b := range base { + if b != 2 && b != 8 && b != 16 { + return SignedResult[[]string]{}, ErrParamRange + } + } + + o := resolveOptions(opts) + if o.Replacement != nil && len(o.Replacement) != n { + return SignedResult[[]string]{}, ErrParamRange + } + if err := validateSignedCommonOptions(o.SignedCommonOptions); err != nil { + return SignedResult[[]string]{}, err + } + + params := generateSignedIntegerSequencesParams{ + baseParams: baseParams{APIKey: r.apiKey}, + N: n, + Length: length, + Min: min, + Max: max, + Replacement: o.Replacement, + Base: base, + PregeneratedRandomization: o.PregeneratedRandomization, + LicenseData: o.LicenseData, + UserData: o.UserData, + TicketID: o.TicketID, + } + + return signedGenerate[[]string](ctx, r, "generateSignedIntegerSequences", params) +} + +// GenerateSignedDecimalFractionsOptions holds optional parameters for GenerateSignedDecimalFractions. +type GenerateSignedDecimalFractionsOptions struct { + // Replacement specifies whether the numbers are picked with + // replacement. nil (the default) behaves like true. + Replacement *bool + SignedCommonOptions +} + +type generateSignedDecimalFractionsParams struct { + baseParams + N int `json:"n"` + DecimalPlaces int `json:"decimalPlaces"` + Replacement *bool `json:"replacement,omitempty"` + PregeneratedRandomization *PregeneratedRandomization `json:"pregeneratedRandomization,omitempty"` + LicenseData *LicenseData `json:"licenseData,omitempty"` + UserData any `json:"userData,omitempty"` + TicketID string `json:"ticketId,omitempty"` +} + +// GenerateSignedDecimalFractions generates n digitally signed decimal +// fractions with decimalPlaces number of decimal places. +func (r *Random) GenerateSignedDecimalFractions(ctx context.Context, n, decimalPlaces int, opts ...GenerateSignedDecimalFractionsOptions) (SignedResult[float64], error) { + if n < 1 || n > 10_000 { + return SignedResult[float64]{}, ErrParamRange + } + if decimalPlaces < 1 || decimalPlaces > 14 { + return SignedResult[float64]{}, ErrParamRange + } + + o := resolveOptions(opts) + if err := validateSignedCommonOptions(o.SignedCommonOptions); err != nil { + return SignedResult[float64]{}, err + } + + params := generateSignedDecimalFractionsParams{ + baseParams: baseParams{APIKey: r.apiKey}, + N: n, + DecimalPlaces: decimalPlaces, + Replacement: o.Replacement, + PregeneratedRandomization: o.PregeneratedRandomization, + LicenseData: o.LicenseData, + UserData: o.UserData, + TicketID: o.TicketID, + } + + return signedGenerate[float64](ctx, r, "generateSignedDecimalFractions", params) +} + +// GenerateSignedGaussiansOptions holds optional parameters for GenerateSignedGaussians. +type GenerateSignedGaussiansOptions struct { + SignedCommonOptions +} + +type generateSignedGaussiansParams struct { + baseParams + N int `json:"n"` + Mean float64 `json:"mean"` + StandardDeviation float64 `json:"standardDeviation"` + SignificantDigits int `json:"significantDigits"` + PregeneratedRandomization *PregeneratedRandomization `json:"pregeneratedRandomization,omitempty"` + LicenseData *LicenseData `json:"licenseData,omitempty"` + UserData any `json:"userData,omitempty"` + TicketID string `json:"ticketId,omitempty"` +} + +// GenerateSignedGaussians generates n digitally signed true random numbers +// from a Gaussian distribution. +func (r *Random) GenerateSignedGaussians(ctx context.Context, n int, mean, standardDeviation float64, significantDigits int, opts ...GenerateSignedGaussiansOptions) (SignedResult[float64], error) { + if n < 1 || n > 10_000 { + return SignedResult[float64]{}, ErrParamRange + } + if mean < -1_000_000 || mean > 1_000_000 { + return SignedResult[float64]{}, ErrParamRange + } + if standardDeviation < -1_000_000 || standardDeviation > 1_000_000 { + return SignedResult[float64]{}, ErrParamRange + } + if significantDigits < 2 || significantDigits > 14 { + return SignedResult[float64]{}, ErrParamRange + } + + o := resolveOptions(opts) + if err := validateSignedCommonOptions(o.SignedCommonOptions); err != nil { + return SignedResult[float64]{}, err + } + + params := generateSignedGaussiansParams{ + baseParams: baseParams{APIKey: r.apiKey}, + N: n, + Mean: mean, + StandardDeviation: standardDeviation, + SignificantDigits: significantDigits, + PregeneratedRandomization: o.PregeneratedRandomization, + LicenseData: o.LicenseData, + UserData: o.UserData, + TicketID: o.TicketID, + } + + return signedGenerate[float64](ctx, r, "generateSignedGaussians", params) +} + +// GenerateSignedStringsOptions holds optional parameters for GenerateSignedStrings. +type GenerateSignedStringsOptions struct { + // Replacement specifies whether the strings are picked with + // replacement. nil (the default) behaves like true. + Replacement *bool + SignedCommonOptions +} + +type generateSignedStringsParams struct { + baseParams + N int `json:"n"` + Length int `json:"length"` + Characters string `json:"characters"` + Replacement *bool `json:"replacement,omitempty"` + PregeneratedRandomization *PregeneratedRandomization `json:"pregeneratedRandomization,omitempty"` + LicenseData *LicenseData `json:"licenseData,omitempty"` + UserData any `json:"userData,omitempty"` + TicketID string `json:"ticketId,omitempty"` +} + +// GenerateSignedStrings generates n digitally signed random strings with +// the given length composed from the characters. +func (r *Random) GenerateSignedStrings(ctx context.Context, n, length int, characters string, opts ...GenerateSignedStringsOptions) (SignedResult[string], error) { + if n < 1 || n > 10_000 { + return SignedResult[string]{}, ErrParamRange + } + if length < 1 || length > 32 { + return SignedResult[string]{}, ErrParamRange + } + if len(characters) < 1 || len(characters) > 128 { + return SignedResult[string]{}, ErrParamRange + } + + o := resolveOptions(opts) + if err := validateSignedCommonOptions(o.SignedCommonOptions); err != nil { + return SignedResult[string]{}, err + } + + params := generateSignedStringsParams{ + baseParams: baseParams{APIKey: r.apiKey}, + N: n, + Length: length, + Characters: characters, + Replacement: o.Replacement, + PregeneratedRandomization: o.PregeneratedRandomization, + LicenseData: o.LicenseData, + UserData: o.UserData, + TicketID: o.TicketID, + } + + return signedGenerate[string](ctx, r, "generateSignedStrings", params) +} + +// GenerateSignedUUIDsOptions holds optional parameters for GenerateSignedUUIDs. +type GenerateSignedUUIDsOptions struct { + SignedCommonOptions +} + +type generateSignedUUIDsParams struct { + baseParams + N int `json:"n"` + PregeneratedRandomization *PregeneratedRandomization `json:"pregeneratedRandomization,omitempty"` + LicenseData *LicenseData `json:"licenseData,omitempty"` + UserData any `json:"userData,omitempty"` + TicketID string `json:"ticketId,omitempty"` +} + +// GenerateSignedUUIDs generates n digitally signed random version 4 UUIDs +// (see section 4.4 of RFC 4122). +func (r *Random) GenerateSignedUUIDs(ctx context.Context, n int, opts ...GenerateSignedUUIDsOptions) (SignedResult[string], error) { + if n < 1 || n > 1_000 { + return SignedResult[string]{}, ErrParamRange + } + + o := resolveOptions(opts) + if err := validateSignedCommonOptions(o.SignedCommonOptions); err != nil { + return SignedResult[string]{}, err + } + + params := generateSignedUUIDsParams{ + baseParams: baseParams{APIKey: r.apiKey}, + N: n, + PregeneratedRandomization: o.PregeneratedRandomization, + LicenseData: o.LicenseData, + UserData: o.UserData, + TicketID: o.TicketID, + } + + return signedGenerate[string](ctx, r, "generateSignedUUIDs", params) +} + +// GenerateSignedBlobsOptions holds optional parameters for GenerateSignedBlobs. +type GenerateSignedBlobsOptions struct { + // Format specifies the encoding used for the returned blobs: + // BlobFormatBase64 (the default) or BlobFormatHex. + Format string + SignedCommonOptions +} + +type generateSignedBlobsParams struct { + baseParams + N int `json:"n"` + Size int `json:"size"` + Format string `json:"format,omitempty"` + PregeneratedRandomization *PregeneratedRandomization `json:"pregeneratedRandomization,omitempty"` + LicenseData *LicenseData `json:"licenseData,omitempty"` + UserData any `json:"userData,omitempty"` + TicketID string `json:"ticketId,omitempty"` +} + +// GenerateSignedBlobs generates n digitally signed random blobs of size (in +// bits, must be divisible by 8). As with GenerateBlobs, n*size must not +// exceed 1,048,576 bits. +func (r *Random) GenerateSignedBlobs(ctx context.Context, n, size int, opts ...GenerateSignedBlobsOptions) (SignedResult[string], error) { + if n < 1 || n > 100 { + return SignedResult[string]{}, ErrParamRange + } + if size < 1 || size > 1_048_576 || size%8 != 0 { + return SignedResult[string]{}, ErrParamRange + } + if n*size > 1_048_576 { + return SignedResult[string]{}, ErrParamRange + } + + o := resolveOptions(opts) + if o.Format != "" && o.Format != BlobFormatBase64 && o.Format != BlobFormatHex { + return SignedResult[string]{}, ErrParamRange + } + if err := validateSignedCommonOptions(o.SignedCommonOptions); err != nil { + return SignedResult[string]{}, err + } + + params := generateSignedBlobsParams{ + baseParams: baseParams{APIKey: r.apiKey}, + N: n, + Size: size, + Format: o.Format, + PregeneratedRandomization: o.PregeneratedRandomization, + LicenseData: o.LicenseData, + UserData: o.UserData, + TicketID: o.TicketID, + } + + return signedGenerate[string](ctx, r, "generateSignedBlobs", params) +} + +type getResultParams struct { + baseParams + SerialNumber int `json:"serialNumber"` +} + +// GetResult retrieves a previously generated Signed API result by its +// SerialNumber (as returned in SignedResult.SerialNumber). RANDOM.ORG +// stores signed results for a minimum of 24 hours so they can be retrieved +// again if, for example, the original response was lost to a network +// failure. It is a package-level function rather than a method because Go +// does not allow methods to introduce their own type parameters; T must +// match the data type originally generated (e.g. int64 for +// generateSignedIntegers, string for generateSignedUUIDs/Strings/Blobs, +// []int64 for generateSignedIntegerSequences, float64 for +// generateSignedDecimalFractions/Gaussians). +func GetResult[T any](ctx context.Context, r *Random, serialNumber int) (SignedResult[T], error) { + params := getResultParams{ + baseParams: baseParams{APIKey: r.apiKey}, + SerialNumber: serialNumber, + } + + env, err := invokeRequest[signedResultEnvelope](ctx, r, "getResult", params) + if err != nil { + return SignedResult[T]{}, err + } + + return decodeSignedResult[T](env) +} + +type verifySignatureParams struct { + Random json.RawMessage `json:"random"` + Signature string `json:"signature"` +} + +type verifySignatureResult struct { + Authenticity bool `json:"authenticity"` +} + +// VerifySignature verifies that random and signature — typically +// SignedResult.Random and SignedResult.Signature from a prior Signed API +// call — were produced by RANDOM.ORG and have not been tampered with. It +// reports the server's authenticity verdict; a false result with a nil +// error means the signature did not match, not that the call failed. +func (r *Random) VerifySignature(ctx context.Context, random json.RawMessage, signature string) (bool, error) { + params := verifySignatureParams{ + Random: random, + Signature: signature, + } + + result, err := invokeRequest[verifySignatureResult](ctx, r, "verifySignature", params) + if err != nil { + return false, err + } + + return result.Authenticity, nil +} diff --git a/signed_test.go b/signed_test.go new file mode 100644 index 0000000..35f7038 --- /dev/null +++ b/signed_test.go @@ -0,0 +1,883 @@ +package randomorg_test + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "slices" + "strings" + "testing" + "time" + + "github.com/sgade/randomorg" +) + +// The response bodies below marked "docs example" are taken verbatim (aside +// from re-indentation) from https://api.random.org/json-rpc/4/signed, so +// that decoding is verified against real RANDOM.ORG output, including real +// signatures. + +func TestGenerateSignedIntegers(t *testing.T) { + t.Run("param validation", func(t *testing.T) { + cases := []struct { + name string + n int + min, max int64 + }{ + {"n too small", 0, 0, 10}, + {"n too large", 10001, 0, 10}, + {"min too small", 1, -1e9 - 1, 10}, + {"max too large", 1, 0, 1e9 + 1}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + random := newTestRandom(t, failOnRequest(t)) + _, err := random.GenerateSignedIntegers(context.Background(), tc.n, tc.min, tc.max) + if !errors.Is(err, randomorg.ErrParamRange) { + t.Fatalf("err = %v, want %v", err, randomorg.ErrParamRange) + } + }) + } + }) + + // docs example: generateSignedIntegers Example 1 (dice roll) + const diceExampleBody = `{ + "jsonrpc": "2.0", + "result": { + "random": { + "method": "generateSignedIntegers", + "hashedApiKey": "ncGk4bCmDT7GSc64MzGzNvRUoDT++pTPjntmtuu075JFqKbz/G4nKerq0JQoldvtQxYOCePxMN5gcYZSOC2DTg==", + "n": 3, + "min": 1, + "max": 6, + "replacement": true, + "base": 10, + "pregeneratedRandomization": null, + "data": [1, 3, 1], + "license": { + "type": "developer", + "text": "Random values licensed strictly for development and testing only", + "infoUrl": null + }, + "licenseData": null, + "userData": null, + "ticketData": null, + "completionTime": "2021-03-15 13:51:32Z", + "serialNumber": 6116 + }, + "signature": "hprai35Zc95uAM47oVpqUTEiVla/GvF+u/8GjZCvcGKRG86fQrnVvuzn1HN5VrJoU13SDE96DmggtTYECzkk9bzfVnhHg47/Zn+7w27GedseB2F4QxNtf7aycvcdBHnSg08IaVo+ohPiqlZcxpx5TVUfmLb6LfYRPirQUHMv5vpT7ba/hDSb7bQ6wGpiV1By48nDC5p/ncZEvfAHQcrNxtrtCbwQoI9BMBxRXqV5DaG6YYPxTpQeg9dWJMhZJuBNWIf4hsCKoOGkyBI/uHPaGgTy5jmSk4cFutK3jQP+9vWkDwYQ9sgok0U9Dgp5jG2zC6JOwaEgosagY7B29r1s6aXxcZCXFtX9yBdAh6Of7Z1PeLeva14lQWdZmqYSYvD56HlYWQfeb0lY2Lgf7Yvr9W/lxUxSg9OUvXi+urR0sprXpGwOcml5dSVRXyG6oyDphwXsvJ8h9ofiCP5rkyxHNphR6s1LF5NQ91OCBDllXiwXAKvJBcBxftFVAJRqpRALuLQB2xTXlrld/XBEBc93Pve3e+B0DancFa1XHgBFLlRSmF+MpSY+8qIT2U4hHSGO38ISSX2RdHYR+talXoQ8Vj6fiibzZCUNMbXp4HcYRjmWUVCii0otGYC/fSg25ZmnpG/SMJXfDbVpzx8sC49qYpaN9GRG5QC5pHfA69nJVqo=", + "cost": 0, + "bitsUsed": 8, + "bitsLeft": 249992, + "requestsLeft": 999, + "advisoryDelay": 2310 + }, + "id": "6995" + }` + + t.Run("generates values (docs example)", func(t *testing.T) { + var gotReq map[string]any + random := newTestRandom(t, func(req *http.Request) (*http.Response, error) { + gotReq = decodeRequestBody(t, req) + return jsonResponse(http.StatusOK, diceExampleBody), nil + }) + + got, err := random.GenerateSignedIntegers(context.Background(), 3, 1, 6, randomorg.GenerateSignedIntegersOptions{ + Replacement: randomorg.Bool(true), + }) + if err != nil { + t.Fatalf("GenerateSignedIntegers() error = %v", err) + } + + if want := []int64{1, 3, 1}; !slices.Equal(got.Data, want) { + t.Errorf("Data = %v, want %v", got.Data, want) + } + if got.SerialNumber != 6116 { + t.Errorf("SerialNumber = %d, want 6116", got.SerialNumber) + } + if got.Signature == "" { + t.Error("Signature is empty") + } + if got.BitsUsed != 8 || got.BitsLeft != 249992 || got.RequestsLeft != 999 || got.AdvisoryDelay != 2310 { + t.Errorf("usage fields = %+v, unexpected", got) + } + if got.License.Type != "developer" { + t.Errorf("License.Type = %q, want developer", got.License.Type) + } + wantTime, _ := time.Parse(time.RFC3339, "2021-03-15T13:51:32Z") + if !got.CompletionTime.Equal(wantTime) { + t.Errorf("CompletionTime = %v, want %v", got.CompletionTime, wantTime) + } + + // Random must carry the exact bytes of the "random" object, since + // it's what VerifySignature (or independent verification) needs. + var raw map[string]any + if err := json.Unmarshal(got.Random, &raw); err != nil { + t.Fatalf("Random did not contain valid JSON: %v", err) + } + if raw["serialNumber"].(float64) != 6116 { + t.Errorf("Random[serialNumber] = %v, want 6116", raw["serialNumber"]) + } + + if gotReq["method"] != "generateSignedIntegers" { + t.Errorf("request method = %v, want generateSignedIntegers", gotReq["method"]) + } + }) + + t.Run("with base", func(t *testing.T) { + t.Run("rejects base 10", func(t *testing.T) { + random := newTestRandom(t, failOnRequest(t)) + _, err := random.GenerateSignedIntegersWithBase(context.Background(), 1, 0, 10, 10) + if !errors.Is(err, randomorg.ErrParamRange) { + t.Fatalf("err = %v, want %v", err, randomorg.ErrParamRange) + } + }) + + t.Run("rejects invalid base", func(t *testing.T) { + random := newTestRandom(t, failOnRequest(t)) + _, err := random.GenerateSignedIntegersWithBase(context.Background(), 1, 0, 10, 3) + if !errors.Is(err, randomorg.ErrParamRange) { + t.Fatalf("err = %v, want %v", err, randomorg.ErrParamRange) + } + }) + + t.Run("decodes string data", func(t *testing.T) { + var gotParams map[string]any + random := newTestRandom(t, func(req *http.Request) (*http.Response, error) { + gotParams, _ = decodeRequestBody(t, req)["params"].(map[string]any) + return jsonResponse(http.StatusOK, `{ + "jsonrpc": "2.0", + "result": { + "random": { + "method": "generateSignedIntegers", + "hashedApiKey": "abc==", + "n": 2, + "min": 0, + "max": 255, + "replacement": true, + "base": 16, + "pregeneratedRandomization": null, + "data": ["0a", "ff"], + "license": {"type": "developer", "text": "dev only", "infoUrl": null}, + "licenseData": null, + "userData": null, + "ticketData": null, + "completionTime": "2021-03-15 13:51:32Z", + "serialNumber": 1 + }, + "signature": "sig==", + "cost": 0, + "bitsUsed": 16, + "bitsLeft": 1, + "requestsLeft": 1, + "advisoryDelay": 0 + }, + "id": "1" + }`), nil + }) + + got, err := random.GenerateSignedIntegersWithBase(context.Background(), 2, 0, 255, 16) + if err != nil { + t.Fatalf("GenerateSignedIntegersWithBase() error = %v", err) + } + if want := []string{"0a", "ff"}; !slices.Equal(got.Data, want) { + t.Errorf("Data = %v, want %v", got.Data, want) + } + if gotParams["base"] != float64(16) { + t.Errorf("params[base] = %v, want 16", gotParams["base"]) + } + }) + }) +} + +func TestGenerateSignedIntegerSequences(t *testing.T) { + t.Run("param validation", func(t *testing.T) { + random := newTestRandom(t, failOnRequest(t)) + _, err := random.GenerateSignedIntegerSequences(context.Background(), 2, []int{1}, []int64{0, 0}, []int64{10, 10}) + if !errors.Is(err, randomorg.ErrParamRange) { + t.Fatalf("err = %v, want %v", err, randomorg.ErrParamRange) + } + }) + + // docs example: generateSignedIntegerSequences Example 1 + t.Run("generates values (docs example)", func(t *testing.T) { + random := newTestRandom(t, func(req *http.Request) (*http.Response, error) { + return jsonResponse(http.StatusOK, `{ + "jsonrpc": "2.0", + "result": { + "random": { + "method": "generateSignedIntegerSequences", + "hashedApiKey": "ncGk4bCmDT7GSc64MzGzNvRUoDT++pTPjntmtuu075JFqKbz/G4nKerq0JQoldvtQxYOCePxMN5gcYZSOC2DTg==", + "n": 2, + "length": [5, 1], + "min": 1, + "max": [69, 26], + "replacement": false, + "base": 10, + "pregeneratedRandomization": null, + "data": [[54, 3, 0, 26, 36], [19]], + "license": { + "type": "developer", + "text": "Random values licensed strictly for development and testing only", + "infoUrl": null + }, + "licenseData": null, + "userData": null, + "ticketData": null, + "completionTime": "2021-03-16 10:13:58Z", + "serialNumber": 6139 + }, + "signature": "ggeQrrjX9M1FFT2Uv4xlz4AIpjMMdPvJfkE0RUIOj6oBsfTLpit+tz9XsNKRgqyoGUnygXW7EWEkzFESXk5QeizLZrEkmEylzC4QOv2Cu5xE7xY+S7jv+BHK/Db5FnBRPOiPiY7KpxSyLBlOZ4PeCshNacsXlFK6nV9SF+CvECMUchA7q8VOr2PYsFVRTVg7vVhRxZD1Qy9ba9TGC+F+TkbNkFJrTGHsqA3KUXeDVDEeueQxDyPsM6Z2gqAt7ciFCRcHcIp52Ik20eLlXiV6PgVppeRk9AHl14cfujB5aUtDsqldGWgARqmxWah4R9RhLzuill3PolB0HTe+VfQr9BcuiHgWazKbDsibhGWCLP3tLKdBpe+ow1xy0fVK6OWsMMzpznahlehl33NmHQI+t6e6uY0yVVlIB0wcTNzTRdrWqJoHD4c36mMgqweZGYwfzpsEnm3SWTyQpCLxxfEqdkuGf9mjLxIN4bvgHtDG9X66sle7jO21Ssvq6F4Hyo6g2yKuJFORSYYu6ukgU3u/MhU08iRBMbT9dvnN30cH1ay/HOULEXu5WHdWYiUMPKWpFqZPiV9yn4rSlGXIqRs6zYCJxz41zL0JTAZMd9eTbx3cCcuK5ws0f8tNfR7Bi1xEOr1F/5U89JXu4wHutqomy4AL7bXd0aNxnQ8taMwH0xk=", + "cost": 0, + "bitsUsed": 36, + "bitsLeft": 249924, + "requestsLeft": 998, + "advisoryDelay": 1560 + }, + "id": "6995" + }`), nil + }) + + got, err := random.GenerateSignedIntegerSequences(context.Background(), 2, []int{5, 1}, []int64{1, 1}, []int64{69, 26}, randomorg.GenerateSignedIntegerSequencesOptions{ + Replacement: []bool{false, false}, + }) + if err != nil { + t.Fatalf("GenerateSignedIntegerSequences() error = %v", err) + } + want := [][]int64{{54, 3, 0, 26, 36}, {19}} + if len(got.Data) != len(want) { + t.Fatalf("Data = %v, want %v", got.Data, want) + } + for i := range want { + if !slices.Equal(got.Data[i], want[i]) { + t.Fatalf("Data[%d] = %v, want %v", i, got.Data[i], want[i]) + } + } + if got.SerialNumber != 6139 { + t.Errorf("SerialNumber = %d, want 6139", got.SerialNumber) + } + }) + + t.Run("with base", func(t *testing.T) { + t.Run("base slice wrong size is rejected", func(t *testing.T) { + random := newTestRandom(t, failOnRequest(t)) + _, err := random.GenerateSignedIntegerSequencesWithBase(context.Background(), 2, []int{1, 1}, []int64{0, 0}, []int64{10, 10}, []int{16}) + if !errors.Is(err, randomorg.ErrParamRange) { + t.Fatalf("err = %v, want %v", err, randomorg.ErrParamRange) + } + }) + + t.Run("base 10 is rejected", func(t *testing.T) { + random := newTestRandom(t, failOnRequest(t)) + _, err := random.GenerateSignedIntegerSequencesWithBase(context.Background(), 1, []int{1}, []int64{0}, []int64{10}, []int{10}) + if !errors.Is(err, randomorg.ErrParamRange) { + t.Fatalf("err = %v, want %v", err, randomorg.ErrParamRange) + } + }) + + t.Run("decodes string data", func(t *testing.T) { + random := newTestRandom(t, func(req *http.Request) (*http.Response, error) { + return jsonResponse(http.StatusOK, `{ + "jsonrpc": "2.0", + "result": { + "random": { + "method": "generateSignedIntegerSequences", + "hashedApiKey": "abc==", + "n": 1, + "length": [2], + "min": [0], + "max": [255], + "replacement": [true], + "base": [16], + "pregeneratedRandomization": null, + "data": [["0a", "ff"]], + "license": {"type": "developer", "text": "dev only", "infoUrl": null}, + "licenseData": null, + "userData": null, + "ticketData": null, + "completionTime": "2021-03-15 13:51:32Z", + "serialNumber": 1 + }, + "signature": "sig==", + "cost": 0, + "bitsUsed": 16, + "bitsLeft": 1, + "requestsLeft": 1, + "advisoryDelay": 0 + }, + "id": "1" + }`), nil + }) + + got, err := random.GenerateSignedIntegerSequencesWithBase(context.Background(), 1, []int{2}, []int64{0}, []int64{255}, []int{16}) + if err != nil { + t.Fatalf("GenerateSignedIntegerSequencesWithBase() error = %v", err) + } + want := [][]string{{"0a", "ff"}} + if len(got.Data) != 1 || !slices.Equal(got.Data[0], want[0]) { + t.Fatalf("Data = %v, want %v", got.Data, want) + } + }) + }) +} + +func TestGenerateSignedDecimalFractions(t *testing.T) { + t.Run("param validation", func(t *testing.T) { + random := newTestRandom(t, failOnRequest(t)) + _, err := random.GenerateSignedDecimalFractions(context.Background(), 0, 2) + if !errors.Is(err, randomorg.ErrParamRange) { + t.Fatalf("err = %v, want %v", err, randomorg.ErrParamRange) + } + }) + + // docs example: generateSignedDecimalFractions Example 1 + t.Run("generates values (docs example)", func(t *testing.T) { + random := newTestRandom(t, func(req *http.Request) (*http.Response, error) { + return jsonResponse(http.StatusOK, `{ + "jsonrpc": "2.0", + "result": { + "random": { + "method": "generateSignedDecimalFractions", + "hashedApiKey": "ncGk4bCmDT7GSc64MzGzNvRUoDT++pTPjntmtuu075JFqKbz/G4nKerq0JQoldvtQxYOCePxMN5gcYZSOC2DTg==", + "n": 10, + "decimalPlaces": 8, + "replacement": true, + "pregeneratedRandomization": null, + "data": [0.23213486, 0.97593769, 0.88911014, 0.63882311, 0.90541324, 0.81344571, 0.69891248, 0.6300596, 0.8323724, 0.17882089], + "license": { + "type": "developer", + "text": "Random values licensed strictly for development and testing only", + "infoUrl": null + }, + "licenseData": null, + "userData": null, + "ticketData": null, + "completionTime": "2021-03-16 11:30:16Z", + "serialNumber": 6156 + }, + "signature": "0arTfIyzLtMA1+Nzo++qeKtSEC2pxCuc4Bb5EsoA3FSTfmEctBQJeuWa4Tl4h/DEPvoiNQ4c/awzjFMOlcN8SsyJnRNSDUpOp8L48gFAvLAyDVflTwe3kJT6N9AAeqIt57m/R+Vzl5RAvS0LMH0tMg1L8mB43aRyZP1rF/YrPJ4dMOugOD+G7Dgqi1U3q+SxcpXoQpbsm1DptpIBMXyBQmxSC6kwkzbPXq4dDY8hcbNG+rcUaWDjbS1ptkFIuDlNhgvY6quC3AiuDt6wYgeLFzGl6WYQ/pGd9B0lklyn3Op2WfgPGqwyhe3FpZ0iWULj8WvdN/ZNkJ67Okz1fRIv7Q/zYIq9btL06Sw+IL2UDSin4Jr/gbjoNpBnfVgvbrQJkieg/aFZIB/KTmTOGfYfsRFsLXSebJALj+F4TWSiEuX7B5qsZq+gNN/5B4GNFoA6khUaJQ9yMaiyi/s7VTI4Au5pvu+lJ+I+tucVJf4uuKKwEuzlpUSxmZZ2HZvYoG2mi8rZo8hTZmGEBjQ4wSZeyp81Y1umZ321qwC42d3sgTS/sLpQaAMTs752zxwJSmGwEkS+NzswGurghtsirNhu0ZhN7yNe3iWX5hqtkncJiEYvH/vXqT90QyUQOvDz3xjOpYYhh/muYTL9kJbypSsKmO8vdiapmGfrVkSEcJFS0GU=", + "cost": 0, + "bitsUsed": 266, + "bitsLeft": 246919, + "requestsLeft": 981, + "advisoryDelay": 2070 + }, + "id": "6995" + }`), nil + }) + + got, err := random.GenerateSignedDecimalFractions(context.Background(), 10, 8) + if err != nil { + t.Fatalf("GenerateSignedDecimalFractions() error = %v", err) + } + if len(got.Data) != 10 { + t.Fatalf("len(Data) = %d, want 10", len(got.Data)) + } + if got.Data[0] != 0.23213486 { + t.Errorf("Data[0] = %v, want 0.23213486", got.Data[0]) + } + if got.SerialNumber != 6156 { + t.Errorf("SerialNumber = %d, want 6156", got.SerialNumber) + } + }) +} + +func TestGenerateSignedGaussians(t *testing.T) { + t.Run("param validation", func(t *testing.T) { + random := newTestRandom(t, failOnRequest(t)) + _, err := random.GenerateSignedGaussians(context.Background(), 0, 0, 1, 4) + if !errors.Is(err, randomorg.ErrParamRange) { + t.Fatalf("err = %v, want %v", err, randomorg.ErrParamRange) + } + }) + + t.Run("generates values", func(t *testing.T) { + var gotMethod string + random := newTestRandom(t, func(req *http.Request) (*http.Response, error) { + gotMethod, _ = decodeRequestBody(t, req)["method"].(string) + return jsonResponse(http.StatusOK, `{ + "jsonrpc": "2.0", + "result": { + "random": { + "method": "generateSignedGaussians", + "hashedApiKey": "abc==", + "n": 4, + "mean": 0, + "standardDeviation": 1, + "significantDigits": 8, + "pregeneratedRandomization": null, + "data": [0.4025041, -1.4918831, 0.64733849, 0.5222242], + "license": {"type": "developer", "text": "dev only", "infoUrl": null}, + "licenseData": null, + "userData": null, + "ticketData": null, + "completionTime": "2013-01-25 19:16:42Z", + "serialNumber": 42 + }, + "signature": "sig==", + "cost": 0, + "bitsUsed": 106, + "bitsLeft": 199894, + "requestsLeft": 5442, + "advisoryDelay": 0 + }, + "id": "1" + }`), nil + }) + + got, err := random.GenerateSignedGaussians(context.Background(), 4, 0, 1, 8) + if err != nil { + t.Fatalf("GenerateSignedGaussians() error = %v", err) + } + if want := []float64{0.4025041, -1.4918831, 0.64733849, 0.5222242}; !slices.Equal(got.Data, want) { + t.Errorf("Data = %v, want %v", got.Data, want) + } + if gotMethod != "generateSignedGaussians" { + t.Errorf("method = %q, want generateSignedGaussians", gotMethod) + } + }) +} + +func TestGenerateSignedStrings(t *testing.T) { + t.Run("param validation", func(t *testing.T) { + random := newTestRandom(t, failOnRequest(t)) + _, err := random.GenerateSignedStrings(context.Background(), 0, 5, "abc") + if !errors.Is(err, randomorg.ErrParamRange) { + t.Fatalf("err = %v, want %v", err, randomorg.ErrParamRange) + } + }) + + t.Run("generates values", func(t *testing.T) { + var gotMethod string + random := newTestRandom(t, func(req *http.Request) (*http.Response, error) { + gotMethod, _ = decodeRequestBody(t, req)["method"].(string) + return jsonResponse(http.StatusOK, `{ + "jsonrpc": "2.0", + "result": { + "random": { + "method": "generateSignedStrings", + "hashedApiKey": "abc==", + "n": 2, + "length": 3, + "characters": "abc", + "replacement": true, + "pregeneratedRandomization": null, + "data": ["abc", "cab"], + "license": {"type": "developer", "text": "dev only", "infoUrl": null}, + "licenseData": null, + "userData": null, + "ticketData": null, + "completionTime": "2011-10-10 13:19:12Z", + "serialNumber": 42 + }, + "signature": "sig==", + "cost": 0, + "bitsUsed": 10, + "bitsLeft": 1, + "requestsLeft": 1, + "advisoryDelay": 0 + }, + "id": "1" + }`), nil + }) + + got, err := random.GenerateSignedStrings(context.Background(), 2, 3, "abc") + if err != nil { + t.Fatalf("GenerateSignedStrings() error = %v", err) + } + if want := []string{"abc", "cab"}; !slices.Equal(got.Data, want) { + t.Errorf("Data = %v, want %v", got.Data, want) + } + if gotMethod != "generateSignedStrings" { + t.Errorf("method = %q, want generateSignedStrings", gotMethod) + } + }) +} + +func TestGenerateSignedUUIDs(t *testing.T) { + t.Run("param validation", func(t *testing.T) { + random := newTestRandom(t, failOnRequest(t)) + _, err := random.GenerateSignedUUIDs(context.Background(), 0) + if !errors.Is(err, randomorg.ErrParamRange) { + t.Fatalf("err = %v, want %v", err, randomorg.ErrParamRange) + } + }) + + t.Run("generates values", func(t *testing.T) { + var gotMethod string + random := newTestRandom(t, func(req *http.Request) (*http.Response, error) { + gotMethod, _ = decodeRequestBody(t, req)["method"].(string) + return jsonResponse(http.StatusOK, `{ + "jsonrpc": "2.0", + "result": { + "random": { + "method": "generateSignedUUIDs", + "hashedApiKey": "abc==", + "n": 1, + "pregeneratedRandomization": null, + "data": ["47849fd4-b790-492e-8b93-c601a91b662d"], + "license": {"type": "developer", "text": "dev only", "infoUrl": null}, + "licenseData": null, + "userData": null, + "ticketData": null, + "completionTime": "2013-02-11 16:42:07Z", + "serialNumber": 42 + }, + "signature": "sig==", + "cost": 0, + "bitsUsed": 122, + "bitsLeft": 998532, + "requestsLeft": 199996, + "advisoryDelay": 1000 + }, + "id": "1" + }`), nil + }) + + got, err := random.GenerateSignedUUIDs(context.Background(), 1) + if err != nil { + t.Fatalf("GenerateSignedUUIDs() error = %v", err) + } + if want := []string{"47849fd4-b790-492e-8b93-c601a91b662d"}; !slices.Equal(got.Data, want) { + t.Errorf("Data = %v, want %v", got.Data, want) + } + if gotMethod != "generateSignedUUIDs" { + t.Errorf("method = %q, want generateSignedUUIDs", gotMethod) + } + }) +} + +func TestGenerateSignedBlobs(t *testing.T) { + t.Run("param validation", func(t *testing.T) { + cases := []struct { + name string + n, size int + }{ + {"n too small", 0, 8}, + {"size not multiple of 8", 1, 7}, + {"aggregate size too large", 2, 1_048_576}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + random := newTestRandom(t, failOnRequest(t)) + _, err := random.GenerateSignedBlobs(context.Background(), tc.n, tc.size) + if !errors.Is(err, randomorg.ErrParamRange) { + t.Fatalf("err = %v, want %v", err, randomorg.ErrParamRange) + } + }) + } + }) + + t.Run("invalid format is rejected", func(t *testing.T) { + random := newTestRandom(t, failOnRequest(t)) + _, err := random.GenerateSignedBlobs(context.Background(), 1, 8, randomorg.GenerateSignedBlobsOptions{Format: "bogus"}) + if !errors.Is(err, randomorg.ErrParamRange) { + t.Fatalf("err = %v, want %v", err, randomorg.ErrParamRange) + } + }) + + t.Run("generates values", func(t *testing.T) { + var gotMethod string + random := newTestRandom(t, func(req *http.Request) (*http.Response, error) { + gotMethod, _ = decodeRequestBody(t, req)["method"].(string) + return jsonResponse(http.StatusOK, `{ + "jsonrpc": "2.0", + "result": { + "random": { + "method": "generateSignedBlobs", + "hashedApiKey": "abc==", + "n": 1, + "size": 8, + "format": "base64", + "pregeneratedRandomization": null, + "data": ["ZGVhZGJlZWY="], + "license": {"type": "developer", "text": "dev only", "infoUrl": null}, + "licenseData": null, + "userData": null, + "ticketData": null, + "completionTime": "2011-10-10 13:19:12Z", + "serialNumber": 42 + }, + "signature": "sig==", + "cost": 0, + "bitsUsed": 8, + "bitsLeft": 1, + "requestsLeft": 1, + "advisoryDelay": 0 + }, + "id": "1" + }`), nil + }) + + got, err := random.GenerateSignedBlobs(context.Background(), 1, 8) + if err != nil { + t.Fatalf("GenerateSignedBlobs() error = %v", err) + } + if want := []string{"ZGVhZGJlZWY="}; !slices.Equal(got.Data, want) { + t.Errorf("Data = %v, want %v", got.Data, want) + } + if gotMethod != "generateSignedBlobs" { + t.Errorf("method = %q, want generateSignedBlobs", gotMethod) + } + }) +} + +func TestSignedCommonOptions_Validation(t *testing.T) { + t.Run("userData too large", func(t *testing.T) { + random := newTestRandom(t, failOnRequest(t)) + _, err := random.GenerateSignedIntegers(context.Background(), 1, 0, 10, randomorg.GenerateSignedIntegersOptions{ + SignedCommonOptions: randomorg.SignedCommonOptions{ + UserData: strings.Repeat("a", 1_001), + }, + }) + if !errors.Is(err, randomorg.ErrParamRange) { + t.Fatalf("err = %v, want %v", err, randomorg.ErrParamRange) + } + }) + + t.Run("pregeneratedRandomization id too long", func(t *testing.T) { + random := newTestRandom(t, failOnRequest(t)) + _, err := random.GenerateSignedIntegers(context.Background(), 1, 0, 10, randomorg.GenerateSignedIntegersOptions{ + SignedCommonOptions: randomorg.SignedCommonOptions{ + PregeneratedRandomization: randomorg.PregeneratedRandomizationByID(strings.Repeat("a", 65)), + }, + }) + if !errors.Is(err, randomorg.ErrParamRange) { + t.Fatalf("err = %v, want %v", err, randomorg.ErrParamRange) + } + }) + + t.Run("licenseData and ticketId are sent", func(t *testing.T) { + var gotParams map[string]any + random := newTestRandom(t, func(req *http.Request) (*http.Response, error) { + gotParams, _ = decodeRequestBody(t, req)["params"].(map[string]any) + return jsonResponse(http.StatusOK, `{ + "jsonrpc": "2.0", + "result": { + "random": { + "method": "generateSignedIntegers", + "hashedApiKey": "abc==", + "n": 1, + "min": 0, + "max": 10, + "replacement": true, + "base": 10, + "pregeneratedRandomization": null, + "data": [5], + "license": {"type": "flexibleGambling", "text": "licensed", "infoUrl": null}, + "licenseData": {"maxPayoutValue": {"currency": "USD", "amount": 100}}, + "userData": null, + "ticketData": {"ticketId": "abc123", "previousTicketId": null, "nextTicketId": null}, + "completionTime": "2021-03-15 13:51:32Z", + "serialNumber": 1 + }, + "signature": "sig==", + "cost": 0.01, + "bitsUsed": 4, + "bitsLeft": 1, + "requestsLeft": 1, + "advisoryDelay": 0 + }, + "id": "1" + }`), nil + }) + + got, err := random.GenerateSignedIntegers(context.Background(), 1, 0, 10, randomorg.GenerateSignedIntegersOptions{ + SignedCommonOptions: randomorg.SignedCommonOptions{ + LicenseData: &randomorg.LicenseData{ + MaxPayoutValue: randomorg.MaxPayoutValue{Currency: "USD", Amount: 100}, + }, + TicketID: "abc123", + }, + }) + if err != nil { + t.Fatalf("GenerateSignedIntegers() error = %v", err) + } + + if got.TicketData == nil || got.TicketData.TicketID != "abc123" { + t.Errorf("TicketData = %+v, want TicketID abc123", got.TicketData) + } + if got.Cost != 0.01 { + t.Errorf("Cost = %v, want 0.01", got.Cost) + } + + params, ok := gotParams["licenseData"].(map[string]any) + if !ok { + t.Fatalf("params[licenseData] missing or wrong type: %v", gotParams["licenseData"]) + } + mpv, ok := params["maxPayoutValue"].(map[string]any) + if !ok || mpv["currency"] != "USD" { + t.Errorf("params[licenseData][maxPayoutValue] = %v, want currency USD", params["maxPayoutValue"]) + } + if gotParams["ticketId"] != "abc123" { + t.Errorf("params[ticketId] = %v, want abc123", gotParams["ticketId"]) + } + }) +} + +func TestGetResult(t *testing.T) { + // docs example: getResult Example 1 + t.Run("success (docs example)", func(t *testing.T) { + var gotReq map[string]any + random := newTestRandom(t, func(req *http.Request) (*http.Response, error) { + gotReq = decodeRequestBody(t, req) + return jsonResponse(http.StatusOK, `{ + "jsonrpc": "2.0", + "result": { + "random": { + "method": "generateSignedIntegers", + "hashedApiKey": "ncGk4bCmDT7GSc64MzGzNvRUoDT++pTPjntmtuu075JFqKbz/G4nKerq0JQoldvtQxYOCePxMN5gcYZSOC2DTg==", + "n": 3, + "min": 1, + "max": 6, + "replacement": true, + "base": 10, + "pregeneratedRandomization": null, + "data": [1, 3, 1], + "license": {"type": "developer", "text": "Random values licensed strictly for development and testing only", "infoUrl": null}, + "licenseData": null, + "userData": null, + "ticketData": null, + "completionTime": "2021-03-15 13:51:32Z", + "serialNumber": 6116 + }, + "signature": "hprai35Zc95uAM47oVpqUTEiVla/GvF+u/8GjZCvcGKRG86fQrnVvuzn1HN5VrJoU13SDE96DmggtTYECzkk9bzfVnhHg47/Zn+7w27GedseB2F4QxNtf7aycvcdBHnSg08IaVo+ohPiqlZcxpx5TVUfmLb6LfYRPirQUHMv5vpT7ba/hDSb7bQ6wGpiV1By48nDC5p/ncZEvfAHQcrNxtrtCbwQoI9BMBxRXqV5DaG6YYPxTpQeg9dWJMhZJuBNWIf4hsCKoOGkyBI/uHPaGgTy5jmSk4cFutK3jQP+9vWkDwYQ9sgok0U9Dgp5jG2zC6JOwaEgosagY7B29r1s6aXxcZCXFtX9yBdAh6Of7Z1PeLeva14lQWdZmqYSYvD56HlYWQfeb0lY2Lgf7Yvr9W/lxUxSg9OUvXi+urR0sprXpGwOcml5dSVRXyG6oyDphwXsvJ8h9ofiCP5rkyxHNphR6s1LF5NQ91OCBDllXiwXAKvJBcBxftFVAJRqpRALuLQB2xTXlrld/XBEBc93Pve3e+B0DancFa1XHgBFLlRSmF+MpSY+8qIT2U4hHSGO38ISSX2RdHYR+talXoQ8Vj6fiibzZCUNMbXp4HcYRjmWUVCii0otGYC/fSg25ZmnpG/SMJXfDbVpzx8sC49qYpaN9GRG5QC5pHfA69nJVqo=", + "cost": 0, + "bitsUsed": 8, + "bitsLeft": 249992, + "requestsLeft": 999, + "advisoryDelay": 2310 + }, + "id": "8337" + }`), nil + }) + + got, err := randomorg.GetResult[int64](context.Background(), random, 6116) + if err != nil { + t.Fatalf("GetResult() error = %v", err) + } + if want := []int64{1, 3, 1}; !slices.Equal(got.Data, want) { + t.Errorf("Data = %v, want %v", got.Data, want) + } + if got.SerialNumber != 6116 { + t.Errorf("SerialNumber = %d, want 6116", got.SerialNumber) + } + + if gotReq["method"] != "getResult" { + t.Errorf("request method = %v, want getResult", gotReq["method"]) + } + params, _ := gotReq["params"].(map[string]any) + if params["serialNumber"] != float64(6116) { + t.Errorf("request params[serialNumber] = %v, want 6116", params["serialNumber"]) + } + }) + + // docs example: getResult Example 2 (unknown apiKey) + t.Run("unknown api key (docs example)", func(t *testing.T) { + random := newTestRandom(t, func(req *http.Request) (*http.Response, error) { + return jsonResponse(http.StatusOK, `{ + "jsonrpc": "2.0", + "error": {"code": 303, "message": "The resource identified by 'apiKey' was not found", "data": ["apiKey"]}, + "id": "13609" + }`), nil + }) + + _, err := randomorg.GetResult[int64](context.Background(), random, 2647656) + var apiErr *randomorg.APIError + if !errors.As(err, &apiErr) { + t.Fatalf("errors.As(err, *APIError) = false, want true (err = %v)", err) + } + if apiErr.Code != 303 { + t.Errorf("apiErr.Code = %d, want 303", apiErr.Code) + } + }) + + // docs example: getResult Example 3 (unknown serialNumber) + t.Run("unknown serial number (docs example)", func(t *testing.T) { + random := newTestRandom(t, func(req *http.Request) (*http.Response, error) { + return jsonResponse(http.StatusOK, `{ + "jsonrpc": "2.0", + "error": {"code": 303, "message": "The resource identified by 'serialNumber' was not found", "data": ["serialNumber"]}, + "id": "28447" + }`), nil + }) + + _, err := randomorg.GetResult[int64](context.Background(), random, 1) + var apiErr *randomorg.APIError + if !errors.As(err, &apiErr) { + t.Fatalf("errors.As(err, *APIError) = false, want true (err = %v)", err) + } + if apiErr.Code != 303 { + t.Errorf("apiErr.Code = %d, want 303", apiErr.Code) + } + }) +} + +func TestVerifySignature(t *testing.T) { + const randomObject = `{ + "method": "generateSignedIntegers", + "hashedApiKey": "ncGk4bCmDT7GSc64MzGzNvRUoDT++pTPjntmtuu075JFqKbz/G4nKerq0JQoldvtQxYOCePxMN5gcYZSOC2DTg==", + "n": 3, + "min": 1, + "max": 6, + "replacement": true, + "base": 10, + "pregeneratedRandomization": null, + "data": [1, 3, 1], + "license": {"type": "developer", "text": "Random values licensed strictly for development and testing only", "infoUrl": null}, + "licenseData": null, + "userData": null, + "ticketData": null, + "completionTime": "2021-03-15 13:51:32Z", + "serialNumber": 6116 + }` + const signature = "hprai35Zc95uAM47oVpqUTEiVla/GvF+u/8GjZCvcGKRG86fQrnVvuzn1HN5VrJoU13SDE96DmggtTYECzkk9bzfVnhHg47/Zn+7w27GedseB2F4QxNtf7aycvcdBHnSg08IaVo+ohPiqlZcxpx5TVUfmLb6LfYRPirQUHMv5vpT7ba/hDSb7bQ6wGpiV1By48nDC5p/ncZEvfAHQcrNxtrtCbwQoI9BMBxRXqV5DaG6YYPxTpQeg9dWJMhZJuBNWIf4hsCKoOGkyBI/uHPaGgTy5jmSk4cFutK3jQP+9vWkDwYQ9sgok0U9Dgp5jG2zC6JOwaEgosagY7B29r1s6aXxcZCXFtX9yBdAh6Of7Z1PeLeva14lQWdZmqYSYvD56HlYWQfeb0lY2Lgf7Yvr9W/lxUxSg9OUvXi+urR0sprXpGwOcml5dSVRXyG6oyDphwXsvJ8h9ofiCP5rkyxHNphR6s1LF5NQ91OCBDllXiwXAKvJBcBxftFVAJRqpRALuLQB2xTXlrld/XBEBc93Pve3e+B0DancFa1XHgBFLlRSmF+MpSY+8qIT2U4hHSGO38ISSX2RdHYR+talXoQ8Vj6fiibzZCUNMbXp4HcYRjmWUVCii0otGYC/fSg25ZmnpG/SMJXfDbVpzx8sC49qYpaN9GRG5QC5pHfA69nJVqo=" + + // docs example: verifySignature Example 1 (authentic) + t.Run("authentic (docs example)", func(t *testing.T) { + var gotReq map[string]any + random := newTestRandom(t, func(req *http.Request) (*http.Response, error) { + gotReq = decodeRequestBody(t, req) + return jsonResponse(http.StatusOK, `{"jsonrpc": "2.0", "result": {"authenticity": true}, "id": "8337"}`), nil + }) + + ok, err := random.VerifySignature(context.Background(), json.RawMessage(randomObject), signature) + if err != nil { + t.Fatalf("VerifySignature() error = %v", err) + } + if !ok { + t.Error("VerifySignature() = false, want true") + } + if gotReq["method"] != "verifySignature" { + t.Errorf("request method = %v, want verifySignature", gotReq["method"]) + } + }) + + // docs example: verifySignature Example 2 (tampered data) + t.Run("tampered data (docs example)", func(t *testing.T) { + random := newTestRandom(t, func(req *http.Request) (*http.Response, error) { + return jsonResponse(http.StatusOK, `{"jsonrpc": "2.0", "result": {"authenticity": false}, "id": "8337"}`), nil + }) + + tampered := strings.Replace(randomObject, `"data": [1, 3, 1]`, `"data": [6, 3, 1]`, 1) + ok, err := random.VerifySignature(context.Background(), json.RawMessage(tampered), signature) + if err != nil { + t.Fatalf("VerifySignature() error = %v", err) + } + if ok { + t.Error("VerifySignature() = true, want false") + } + }) + + t.Run("http error propagates", func(t *testing.T) { + random := newTestRandom(t, func(*http.Request) (*http.Response, error) { + return jsonResponse(http.StatusInternalServerError, ""), nil + }) + + _, err := random.VerifySignature(context.Background(), json.RawMessage(randomObject), signature) + if !errors.Is(err, randomorg.ErrHTTPStatus) { + t.Fatalf("err = %v, want %v", err, randomorg.ErrHTTPStatus) + } + }) +} diff --git a/tickets.go b/tickets.go new file mode 100644 index 0000000..5ec4e62 --- /dev/null +++ b/tickets.go @@ -0,0 +1,261 @@ +package randomorg + +import ( + "context" + "encoding/json" + "time" +) + +// Tickets +// see https://api.random.org/json-rpc/4/signed + +// Ticket is a single ticket as returned by CreateTickets. +type Ticket struct { + TicketID string + CreationTime time.Time + PreviousTicketID *string + NextTicketID *string +} + +// ticketEnvelope is the wire shape of a single ticket in the createTickets response. +type ticketEnvelope struct { + TicketID string `json:"ticketId"` + CreationTime string `json:"creationTime"` + PreviousTicketID *string `json:"previousTicketId"` + NextTicketID *string `json:"nextTicketId"` +} + +func (e ticketEnvelope) toTicket() (Ticket, error) { + creationTime, err := parseAPITime(e.CreationTime) + if err != nil { + return Ticket{}, err + } + + return Ticket{ + TicketID: e.TicketID, + CreationTime: creationTime, + PreviousTicketID: e.PreviousTicketID, + NextTicketID: e.NextTicketID, + }, nil +} + +type createTicketsParams struct { + baseParams + N int `json:"n"` + ShowResult bool `json:"showResult"` +} + +// CreateTickets creates n tickets (n in [1, 50]) that can be used, one at a +// time, in place of a TicketID when calling a GenerateSigned* method. +// showResult controls how much detail GetTicket later returns for each +// ticket: if false, only basic ticket information; if true, the full +// signed result produced when the ticket was used (see +// TicketDetails.Result and DecodeTicketResult). +func (r *Random) CreateTickets(ctx context.Context, n int, showResult bool) ([]Ticket, error) { + if n < 1 || n > 50 { + return nil, ErrParamRange + } + + params := createTicketsParams{ + baseParams: baseParams{APIKey: r.apiKey}, + N: n, + ShowResult: showResult, + } + + envelopes, err := invokeRequest[[]ticketEnvelope](ctx, r, "createTickets", params) + if err != nil { + return nil, err + } + + tickets := make([]Ticket, len(envelopes)) + for i, e := range envelopes { + ticket, err := e.toTicket() + if err != nil { + return nil, err + } + tickets[i] = ticket + } + + return tickets, nil +} + +type revealTicketsParams struct { + baseParams + TicketID string `json:"ticketId"` +} + +type revealTicketsResult struct { + TicketCount int `json:"ticketCount"` +} + +// RevealTickets marks ticketID and every predecessor in its chain as +// revealed, meaning subsequent GetTicket calls return their full details +// (as if they had been created with showResult true). It only affects +// tickets that have already been used, and reports how many tickets were +// revealed by the call. +func (r *Random) RevealTickets(ctx context.Context, ticketID string) (int, error) { + params := revealTicketsParams{ + baseParams: baseParams{APIKey: r.apiKey}, + TicketID: ticketID, + } + + result, err := invokeRequest[revealTicketsResult](ctx, r, "revealTickets", params) + if err != nil { + return 0, err + } + + return result.TicketCount, nil +} + +// TicketType selects which tickets ListTickets returns. +type TicketType string + +const ( + // TicketTypeSingleton selects tickets that are the only ticket in + // their chain, i.e. that have neither a previous nor a next ticket. + TicketTypeSingleton TicketType = "singleton" + // TicketTypeHead selects tickets that are the first in their chain but + // have a next ticket. + TicketTypeHead TicketType = "head" + // TicketTypeTail selects tickets that have a previous ticket but are + // the last (and always unused) ticket in their chain. + TicketTypeTail TicketType = "tail" +) + +// TicketDetails describes a ticket as returned by ListTickets or GetTicket. +type TicketDetails struct { + TicketID string + HashedAPIKey string + ShowResult bool + CreationTime time.Time + UsedTime *time.Time + SerialNumber *int + ExpirationTime *time.Time + PreviousTicketID *string + NextTicketID *string + + // Result is the full signed result produced when the ticket was used. + // It is only ever populated by GetTicket (never by ListTickets), and + // only when the ticket was created with showResult true and has + // already been used; otherwise it is nil. Decode it with + // DecodeTicketResult. + Result json.RawMessage +} + +// ticketDetailsEnvelope is the wire shape returned by listTickets (as an +// array element) and getTicket (as the whole result). +type ticketDetailsEnvelope struct { + TicketID string `json:"ticketId"` + HashedAPIKey string `json:"hashedApiKey"` + ShowResult bool `json:"showResult"` + CreationTime string `json:"creationTime"` + UsedTime *string `json:"usedTime"` + SerialNumber *int `json:"serialNumber"` + ExpirationTime *string `json:"expirationTime"` + PreviousTicketID *string `json:"previousTicketId"` + NextTicketID *string `json:"nextTicketId"` + Result json.RawMessage `json:"result"` +} + +func (e ticketDetailsEnvelope) toTicketDetails() (TicketDetails, error) { + creationTime, err := parseAPITime(e.CreationTime) + if err != nil { + return TicketDetails{}, err + } + + details := TicketDetails{ + TicketID: e.TicketID, + HashedAPIKey: e.HashedAPIKey, + ShowResult: e.ShowResult, + CreationTime: creationTime, + SerialNumber: e.SerialNumber, + PreviousTicketID: e.PreviousTicketID, + NextTicketID: e.NextTicketID, + } + + if e.UsedTime != nil { + usedTime, err := parseAPITime(*e.UsedTime) + if err != nil { + return TicketDetails{}, err + } + details.UsedTime = &usedTime + } + if e.ExpirationTime != nil { + expirationTime, err := parseAPITime(*e.ExpirationTime) + if err != nil { + return TicketDetails{}, err + } + details.ExpirationTime = &expirationTime + } + + // A ticket created with showResult true but not yet used reports a + // literal JSON null for "result" (rather than omitting the key). + // Normalize that to nil, same as an omitted or showResult-false ticket, + // so callers only need one nil check. + if len(e.Result) > 0 && string(e.Result) != "null" { + details.Result = e.Result + } + + return details, nil +} + +type listTicketsParams struct { + baseParams + TicketType TicketType `json:"ticketType"` +} + +// ListTickets returns up to 4000 tickets of ticketType belonging to the +// client's API key. +func (r *Random) ListTickets(ctx context.Context, ticketType TicketType) ([]TicketDetails, error) { + params := listTicketsParams{ + baseParams: baseParams{APIKey: r.apiKey}, + TicketType: ticketType, + } + + envelopes, err := invokeRequest[[]ticketDetailsEnvelope](ctx, r, "listTickets", params) + if err != nil { + return nil, err + } + + tickets := make([]TicketDetails, len(envelopes)) + for i, e := range envelopes { + details, err := e.toTicketDetails() + if err != nil { + return nil, err + } + tickets[i] = details + } + + return tickets, nil +} + +type getTicketParams struct { + TicketID string `json:"ticketId"` +} + +// GetTicket returns the details of a single ticket by its ticketID. Unlike +// most methods, it does not take an API key: any ticket ID can be looked up +// by anyone who has it, though the full random-value Result is only ever +// present when the ticket was created with showResult true. +func (r *Random) GetTicket(ctx context.Context, ticketID string) (TicketDetails, error) { + params := getTicketParams{TicketID: ticketID} + + env, err := invokeRequest[ticketDetailsEnvelope](ctx, r, "getTicket", params) + if err != nil { + return TicketDetails{}, err + } + + return env.toTicketDetails() +} + +// DecodeTicketResult decodes TicketDetails.Result, as returned by +// GetTicket, into a typed SignedResult[T]. T must match the data type +// originally generated, as with GetResult. +func DecodeTicketResult[T any](raw json.RawMessage) (SignedResult[T], error) { + var env signedResultEnvelope + if err := json.Unmarshal(raw, &env); err != nil { + return SignedResult[T]{}, err + } + + return decodeSignedResult[T](env) +} diff --git a/tickets_test.go b/tickets_test.go new file mode 100644 index 0000000..bdd9412 --- /dev/null +++ b/tickets_test.go @@ -0,0 +1,447 @@ +package randomorg_test + +import ( + "context" + "errors" + "net/http" + "testing" + + "github.com/sgade/randomorg" +) + +// The response bodies below marked "docs example" are taken from +// https://api.random.org/json-rpc/4/signed (the createTickets, revealTickets, +// listTickets and getTicket sections). + +func TestCreateTickets(t *testing.T) { + t.Run("param validation", func(t *testing.T) { + cases := []struct { + name string + n int + }{ + {"n too small", 0}, + {"n too large", 51}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + random := newTestRandom(t, failOnRequest(t)) + _, err := random.CreateTickets(context.Background(), tc.n, false) + if !errors.Is(err, randomorg.ErrParamRange) { + t.Fatalf("err = %v, want %v", err, randomorg.ErrParamRange) + } + }) + } + }) + + // docs example: createTickets Example 1 (showResult: false) + t.Run("showResult false (docs example)", func(t *testing.T) { + var gotParams map[string]any + random := newTestRandom(t, func(req *http.Request) (*http.Response, error) { + gotParams, _ = decodeRequestBody(t, req)["params"].(map[string]any) + return jsonResponse(http.StatusOK, `{ + "jsonrpc": "2.0", + "result": [ + {"ticketId": "ca71d8928623cee5", "creationTime": "2021-03-26 14:43:54Z", "previousTicketId": null, "nextTicketId": null}, + {"ticketId": "8d8b2bded2c3ed28", "creationTime": "2021-03-26 14:43:54Z", "previousTicketId": null, "nextTicketId": null} + ], + "id": "22746" + }`), nil + }) + + got, err := random.CreateTickets(context.Background(), 2, false) + if err != nil { + t.Fatalf("CreateTickets() error = %v", err) + } + if len(got) != 2 { + t.Fatalf("len(CreateTickets()) = %d, want 2", len(got)) + } + if got[0].TicketID != "ca71d8928623cee5" { + t.Errorf("got[0].TicketID = %q, want ca71d8928623cee5", got[0].TicketID) + } + if got[0].PreviousTicketID != nil || got[0].NextTicketID != nil { + t.Errorf("got[0] chain pointers = %+v, want both nil", got[0]) + } + if got[0].CreationTime.IsZero() { + t.Error("got[0].CreationTime is zero") + } + + if gotParams["showResult"] != false { + t.Errorf("params[showResult] = %v, want false", gotParams["showResult"]) + } + if gotParams["n"] != float64(2) { + t.Errorf("params[n] = %v, want 2", gotParams["n"]) + } + }) + + // docs example: createTickets Example 2 (showResult: true) + t.Run("showResult true (docs example)", func(t *testing.T) { + var gotParams map[string]any + random := newTestRandom(t, func(req *http.Request) (*http.Response, error) { + gotParams, _ = decodeRequestBody(t, req)["params"].(map[string]any) + return jsonResponse(http.StatusOK, `{ + "jsonrpc": "2.0", + "result": [ + {"ticketId": "992104b84ba7aed6", "creationTime": "2021-03-26 14:43:27Z", "previousTicketId": null, "nextTicketId": null}, + {"ticketId": "5f279f7a7aecdcd3", "creationTime": "2021-03-26 14:43:27Z", "previousTicketId": null, "nextTicketId": null} + ], + "id": "22746" + }`), nil + }) + + got, err := random.CreateTickets(context.Background(), 2, true) + if err != nil { + t.Fatalf("CreateTickets() error = %v", err) + } + if len(got) != 2 || got[1].TicketID != "5f279f7a7aecdcd3" { + t.Fatalf("CreateTickets() = %+v, unexpected", got) + } + if gotParams["showResult"] != true { + t.Errorf("params[showResult] = %v, want true", gotParams["showResult"]) + } + }) +} + +func TestRevealTickets(t *testing.T) { + // docs example: revealTickets Example 1 (first in chain) + t.Run("first in chain (docs example)", func(t *testing.T) { + var gotParams map[string]any + random := newTestRandom(t, func(req *http.Request) (*http.Response, error) { + gotParams, _ = decodeRequestBody(t, req)["params"].(map[string]any) + return jsonResponse(http.StatusOK, `{"jsonrpc": "2.0", "result": {"ticketCount": 1}, "id": "18873"}`), nil + }) + + got, err := random.RevealTickets(context.Background(), "ca71d8928623cee5") + if err != nil { + t.Fatalf("RevealTickets() error = %v", err) + } + if got != 1 { + t.Errorf("RevealTickets() = %d, want 1", got) + } + if gotParams["ticketId"] != "ca71d8928623cee5" { + t.Errorf("params[ticketId] = %v, want ca71d8928623cee5", gotParams["ticketId"]) + } + }) + + // docs example: revealTickets Example 2 (cascades to predecessors) + t.Run("cascades to predecessors (docs example)", func(t *testing.T) { + random := newTestRandom(t, func(req *http.Request) (*http.Response, error) { + return jsonResponse(http.StatusOK, `{"jsonrpc": "2.0", "result": {"ticketCount": 3}, "id": "5862"}`), nil + }) + + got, err := random.RevealTickets(context.Background(), "2b08a317fa982ec6") + if err != nil { + t.Fatalf("RevealTickets() error = %v", err) + } + if got != 3 { + t.Errorf("RevealTickets() = %d, want 3", got) + } + }) + + // docs example: revealTickets Example 3 (tail ticket, not yet used) + t.Run("unused tail ticket errors (docs example)", func(t *testing.T) { + random := newTestRandom(t, func(req *http.Request) (*http.Response, error) { + return jsonResponse(http.StatusOK, `{ + "jsonrpc": "2.0", + "error": {"code": 426, "message": "The ticket you specified has not yet been used", "data": null}, + "id": "20944" + }`), nil + }) + + _, err := random.RevealTickets(context.Background(), "ea2c0d31720d66e4") + var apiErr *randomorg.APIError + if !errors.As(err, &apiErr) { + t.Fatalf("errors.As(err, *APIError) = false, want true (err = %v)", err) + } + if apiErr.Code != 426 { + t.Errorf("apiErr.Code = %d, want 426", apiErr.Code) + } + }) +} + +func TestListTickets(t *testing.T) { + // docs example: listTickets Example 1 (ticketType: singleton) + t.Run("singleton (docs example)", func(t *testing.T) { + var gotParams map[string]any + random := newTestRandom(t, func(req *http.Request) (*http.Response, error) { + gotParams, _ = decodeRequestBody(t, req)["params"].(map[string]any) + return jsonResponse(http.StatusOK, `{ + "jsonrpc": "2.0", + "result": [ + { + "ticketId": "5f279f7a7aecdcd3", + "hashedApiKey": "ncGk4bCmDT7GSc64MzGzNvRUoDT++pTPjntmtuu075JFqKbz/G4nKerq0JQoldvtQxYOCePxMN5gcYZSOC2DTg==", + "showResult": true, + "creationTime": "2021-03-26 14:43:27Z", + "usedTime": null, + "serialNumber": null, + "expirationTime": "2021-04-25 14:43:27Z", + "previousTicketId": null, + "nextTicketId": null + }, + { + "ticketId": "8d8b2bded2c3ed28", + "hashedApiKey": "ncGk4bCmDT7GSc64MzGzNvRUoDT++pTPjntmtuu075JFqKbz/G4nKerq0JQoldvtQxYOCePxMN5gcYZSOC2DTg==", + "showResult": false, + "creationTime": "2021-03-26 14:43:54Z", + "usedTime": null, + "serialNumber": null, + "expirationTime": "2021-04-25 14:43:54Z", + "previousTicketId": null, + "nextTicketId": null + } + ], + "id": "22746" + }`), nil + }) + + got, err := random.ListTickets(context.Background(), randomorg.TicketTypeSingleton) + if err != nil { + t.Fatalf("ListTickets() error = %v", err) + } + if len(got) != 2 { + t.Fatalf("len(ListTickets()) = %d, want 2", len(got)) + } + if !got[0].ShowResult { + t.Error("got[0].ShowResult = false, want true") + } + if got[0].UsedTime != nil { + t.Errorf("got[0].UsedTime = %v, want nil (unused ticket)", got[0].UsedTime) + } + if got[0].SerialNumber != nil { + t.Errorf("got[0].SerialNumber = %v, want nil (unused ticket)", got[0].SerialNumber) + } + if got[0].ExpirationTime == nil || got[0].ExpirationTime.IsZero() { + t.Error("got[0].ExpirationTime is nil or zero") + } + + if gotParams["ticketType"] != "singleton" { + t.Errorf("params[ticketType] = %v, want singleton", gotParams["ticketType"]) + } + }) + + // docs example: listTickets Example 2 (ticketType: head, used tickets) + t.Run("head, used tickets (docs example)", func(t *testing.T) { + random := newTestRandom(t, func(req *http.Request) (*http.Response, error) { + return jsonResponse(http.StatusOK, `{ + "jsonrpc": "2.0", + "result": [ + { + "ticketId": "992104b84ba7aed6", + "hashedApiKey": "ncGk4bCmDT7GSc64MzGzNvRUoDT++pTPjntmtuu075JFqKbz/G4nKerq0JQoldvtQxYOCePxMN5gcYZSOC2DTg==", + "showResult": true, + "creationTime": "2021-03-26 14:43:27Z", + "usedTime": "2021-03-26 15:19:59Z", + "serialNumber": 6277, + "expirationTime": "2021-04-25 14:43:27Z", + "previousTicketId": null, + "nextTicketId": "448c8e3467a07577" + } + ], + "id": "22746" + }`), nil + }) + + got, err := random.ListTickets(context.Background(), randomorg.TicketTypeHead) + if err != nil { + t.Fatalf("ListTickets() error = %v", err) + } + if len(got) != 1 { + t.Fatalf("len(ListTickets()) = %d, want 1", len(got)) + } + if got[0].UsedTime == nil || got[0].UsedTime.IsZero() { + t.Error("got[0].UsedTime is nil or zero") + } + if got[0].SerialNumber == nil || *got[0].SerialNumber != 6277 { + t.Errorf("got[0].SerialNumber = %v, want 6277", got[0].SerialNumber) + } + if got[0].NextTicketID == nil || *got[0].NextTicketID != "448c8e3467a07577" { + t.Errorf("got[0].NextTicketID = %v, want 448c8e3467a07577", got[0].NextTicketID) + } + }) +} + +func TestGetTicket(t *testing.T) { + // docs example: getTicket Example 1 (used, showResult false) + t.Run("used, showResult false (docs example)", func(t *testing.T) { + var gotParams map[string]any + random := newTestRandom(t, func(req *http.Request) (*http.Response, error) { + gotParams, _ = decodeRequestBody(t, req)["params"].(map[string]any) + return jsonResponse(http.StatusOK, `{ + "jsonrpc": "2.0", + "result": { + "ticketId": "ca71d8928623cee5", + "hashedApiKey": "ncGk4bCmDT7GSc64MzGzNvRUoDT++pTPjntmtuu075JFqKbz/G4nKerq0JQoldvtQxYOCePxMN5gcYZSOC2DTg==", + "showResult": false, + "creationTime": "2021-03-26 14:43:54Z", + "usedTime": "2021-03-26 15:18:32Z", + "serialNumber": 6276, + "expirationTime": "2021-04-25 14:43:54Z", + "previousTicketId": null, + "nextTicketId": "d7563dedd09b6b80" + }, + "id": "22746" + }`), nil + }) + + got, err := random.GetTicket(context.Background(), "ca71d8928623cee5") + if err != nil { + t.Fatalf("GetTicket() error = %v", err) + } + if got.SerialNumber == nil || *got.SerialNumber != 6276 { + t.Errorf("SerialNumber = %v, want 6276", got.SerialNumber) + } + if got.Result != nil { + t.Errorf("Result = %v, want nil (showResult was false)", got.Result) + } + if gotParams["ticketId"] != "ca71d8928623cee5" { + t.Errorf("params[ticketId] = %v, want ca71d8928623cee5", gotParams["ticketId"]) + } + if _, ok := gotParams["apiKey"]; ok { + t.Errorf("params[apiKey] present = %v, want absent (getTicket takes no apiKey)", gotParams["apiKey"]) + } + }) + + // docs example: getTicket Example 2 (unused) + t.Run("unused (docs example)", func(t *testing.T) { + random := newTestRandom(t, func(req *http.Request) (*http.Response, error) { + return jsonResponse(http.StatusOK, `{ + "jsonrpc": "2.0", + "result": { + "ticketId": "8d8b2bded2c3ed28", + "hashedApiKey": "ncGk4bCmDT7GSc64MzGzNvRUoDT++pTPjntmtuu075JFqKbz/G4nKerq0JQoldvtQxYOCePxMN5gcYZSOC2DTg==", + "showResult": false, + "creationTime": "2021-03-26 14:43:54Z", + "usedTime": null, + "serialNumber": null, + "expirationTime": "2021-04-25 14:43:54Z", + "previousTicketId": null, + "nextTicketId": null + }, + "id": "22746" + }`), nil + }) + + got, err := random.GetTicket(context.Background(), "8d8b2bded2c3ed28") + if err != nil { + t.Fatalf("GetTicket() error = %v", err) + } + if got.UsedTime != nil || got.SerialNumber != nil { + t.Errorf("unused ticket = %+v, want UsedTime and SerialNumber nil", got) + } + }) + + // docs example: getTicket Example 3 (used, showResult true, full nested result) + t.Run("used, showResult true (docs example)", func(t *testing.T) { + random := newTestRandom(t, func(req *http.Request) (*http.Response, error) { + return jsonResponse(http.StatusOK, `{ + "jsonrpc": "2.0", + "result": { + "ticketId": "992104b84ba7aed6", + "hashedApiKey": "ncGk4bCmDT7GSc64MzGzNvRUoDT++pTPjntmtuu075JFqKbz/G4nKerq0JQoldvtQxYOCePxMN5gcYZSOC2DTg==", + "showResult": true, + "creationTime": "2021-03-26 14:43:27Z", + "usedTime": "2021-03-26 15:19:59Z", + "serialNumber": 6277, + "expirationTime": "2021-04-25 14:43:27Z", + "previousTicketId": null, + "nextTicketId": "448c8e3467a07577", + "result": { + "random": { + "method": "generateSignedIntegers", + "hashedApiKey": "ncGk4bCmDT7GSc64MzGzNvRUoDT++pTPjntmtuu075JFqKbz/G4nKerq0JQoldvtQxYOCePxMN5gcYZSOC2DTg==", + "n": 1, + "min": 0, + "max": 36, + "replacement": true, + "base": 10, + "pregeneratedRandomization": null, + "data": [6], + "license": {"type": "developer", "text": "Random values licensed strictly for development and testing only", "infoUrl": null}, + "licenseData": null, + "userData": null, + "ticketData": {"ticketId": "992104b84ba7aed6", "previousTicketId": null, "nextTicketId": "448c8e3467a07577"}, + "completionTime": "2021-03-26 15:19:59Z", + "serialNumber": 6277 + }, + "signature": "DNELqzKkBC78nAXPk5+TnrolPSY3mzpXYXHdmrOHjyWSDAPE2YICg+02qP5pJR2xjqv+UUl0o52GHRqAB6o75cAa8qd6b6F724M7tAzlZWHKH7Z16/HGDPf82HnMvyd4xA5n0/A4vlvoX9A63hjz30O0qaivqdYEHJOevu9l3e6Q2QVMrMkd3GxCrILOquNZAjrWorMKvHITrJh8zwVxZSDU4mjGX3GEHuFBsImJloQaDrxabgZH5Sc15F6076ULfZ7dzE0W8x3+xm4IeckOo4/Z8jMV0W6AxSmAJEK2dq4xLSyWIVR6wpiPS5v0z9aHkhu6+uXh3UyQVLq3hglCm6Gx4cRTFO+vq1I5xOCXHvQc1RtWYsLbSvLWUCnDQdDwpXIq5kpYhgbbnR1tQmlsmkQOzaHF7IYoSKcg8JGM5y1fDldE+RaUgkQmMEmAMJ9SLs/67W5OW5Gjetqlg4k1rENx7PiZQ91DxJWaIA+G3v3qABDuSNVNSkqLJS6eUvAZu8lLX57FBvwYbbMH41d4fdxnJNk1jkzxeLn3PoUZ6OEnDNCdQv37xaeP/McHwQF9yazNuc/8LyNv6amkzHM0qZooXfbFgV/q3MSDhQ97wEDUiMlEJkIwFw1HFMT8aHA7ChhLxSJtINmWCfjRKZ0p3FRiDTk+uz6doKeXRuckKWo=", + "cost": 0, + "bitsUsed": 5, + "bitsLeft": 249990, + "requestsLeft": 992, + "advisoryDelay": 2430 + } + }, + "id": "22746" + }`), nil + }) + + got, err := random.GetTicket(context.Background(), "992104b84ba7aed6") + if err != nil { + t.Fatalf("GetTicket() error = %v", err) + } + if got.Result == nil { + t.Fatal("Result = nil, want the nested signed result") + } + + decoded, err := randomorg.DecodeTicketResult[int64](got.Result) + if err != nil { + t.Fatalf("DecodeTicketResult() error = %v", err) + } + if want := []int64{6}; decoded.Data[0] != want[0] { + t.Errorf("decoded.Data = %v, want %v", decoded.Data, want) + } + if decoded.TicketData == nil || decoded.TicketData.TicketID != "992104b84ba7aed6" { + t.Errorf("decoded.TicketData = %+v, want TicketID 992104b84ba7aed6", decoded.TicketData) + } + }) + + // docs example: getTicket Example 4 (unused, showResult true -> result: null) + t.Run("unused, showResult true (docs example)", func(t *testing.T) { + random := newTestRandom(t, func(req *http.Request) (*http.Response, error) { + return jsonResponse(http.StatusOK, `{ + "jsonrpc": "2.0", + "result": { + "ticketId": "5f279f7a7aecdcd3", + "hashedApiKey": "ncGk4bCmDT7GSc64MzGzNvRUoDT++pTPjntmtuu075JFqKbz/G4nKerq0JQoldvtQxYOCePxMN5gcYZSOC2DTg==", + "showResult": true, + "creationTime": "2021-03-26 14:43:27Z", + "usedTime": null, + "serialNumber": null, + "expirationTime": "2021-04-25 14:43:27Z", + "previousTicketId": null, + "nextTicketId": null, + "result": null + }, + "id": "22746" + }`), nil + }) + + got, err := random.GetTicket(context.Background(), "5f279f7a7aecdcd3") + if err != nil { + t.Fatalf("GetTicket() error = %v", err) + } + if got.Result != nil { + t.Errorf("Result = %v, want nil (literal JSON null should normalize to nil)", got.Result) + } + }) + + // docs example: getTicket Example 5 (nonexistent) + t.Run("nonexistent (docs example)", func(t *testing.T) { + random := newTestRandom(t, func(req *http.Request) (*http.Response, error) { + return jsonResponse(http.StatusOK, `{ + "jsonrpc": "2.0", + "error": {"code": 420, "message": "The ticket you specified does not exist", "data": null}, + "id": "13354" + }`), nil + }) + + _, err := random.GetTicket(context.Background(), "7777777777777777") + var apiErr *randomorg.APIError + if !errors.As(err, &apiErr) { + t.Fatalf("errors.As(err, *APIError) = false, want true (err = %v)", err) + } + if apiErr.Code != 420 { + t.Errorf("apiErr.Code = %d, want 420", apiErr.Code) + } + }) +} diff --git a/usage.go b/usage.go index 24e56b7..5ebd3cf 100644 --- a/usage.go +++ b/usage.go @@ -2,7 +2,6 @@ package randomorg import ( "context" - "strings" "time" ) @@ -57,9 +56,7 @@ func (r *Random) mergeUsage(fields usageFields) { } if fields.CreationTime != nil { - // fix so that we can parse it - creationTimeString := strings.Replace(*fields.CreationTime, " ", "T", 1) - creationTime, err := time.Parse(creationTimeLayout, creationTimeString) + creationTime, err := parseAPITime(*fields.CreationTime) if err == nil { usage.CreationTime = creationTime } else {