Files
cloud/models/contract_test.go
T
Dmitry Ng 242b79e97a feat: rework update/support contracts, fix SDK retry body replay, and refresh docs for v1.0.0
- Split reported components into images vs. files, add update strategies and per-stack resolution, and a shared action/reason vocabulary for update answers
- Add models.ParseEnvelope[T] and MsgLogTypeWait to match the server's response contract; fix SDK retries silently resending an exhausted request body
- Update examples/report-errors to continue issues via -issue-id and render streamed answers live; refresh README/API.md/doc.go for the license key flow
2026-08-12 20:09:30 +03:00

877 lines
31 KiB
Go

package models
import (
"encoding/json"
"reflect"
"strings"
"testing"
)
// GoldenCheckUpdatesRequest is the exact wire form this package produces for an
// update check. The server side keeps a copy of this literal and asserts its own
// models accept it, so a field renamed on either side turns one of the two tests
// red instead of silently producing requests nobody can parse.
const GoldenCheckUpdatesRequest = `{
"installer_version": "2.0.0-87ac00f",
"installer_os": "linux",
"installer_arch": "arm64",
"strategy": "stable",
"images": [
{
"component": "pentagi",
"status": "running",
"os": "linux",
"arch": "arm64",
"repository": "vxcontrol/pentagi",
"tag": "latest",
"image_hash": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
},
{
"component": "postgres",
"status": "running",
"os": "linux",
"arch": "arm64",
"repository": "postgres",
"tag": "15",
"image_hash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
}
],
"stacks": [
{
"stack": "langfuse",
"status": "external"
},
{
"stack": "observability",
"status": "unused"
}
],
"files": [
{
"component": "installer",
"status": "installed",
"os": "darwin",
"arch": "arm64",
"version": "2.0.0-87ac00f",
"file_hash": "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210"
},
{
"component": "engine",
"status": "installed",
"os": "linux",
"arch": "arm64",
"version": "1.0.0"
}
],
"info": {"schema":1,"version":"0.9.3","counts":{"users":3}}
}`
// GoldenCheckUpdatesResponse is the exact wire form the server answers with,
// wrapped in the envelope every JSON response carries.
//
// It deliberately carries THREE stacks, and two of them resolve to nothing. A
// fixture made only of resolved artefacts pins only the fields a resolved
// artefact has: `reason` and `release_version` are optional, so leaving them out
// leaves both sides free to rename or drop them without a single test noticing.
// An unresolved artefact is the only way to pin `reason` — the server rejects it
// for every action except `unknown` — and `has_update: false` is the answer most
// installations get most of the time, so it is the shape most worth pinning.
//
// The two boolean flags are set rather than left false for the same reason:
// `omitempty` on a bool makes an unset flag invisible on the wire, so a fixture
// that leaves them false pins nothing. `releases` is shortened to two entries to
// keep the literal readable — the truncation flag is what a twenty-one-release
// jump raises, and its presence here is about the field, not the count.
const GoldenCheckUpdatesResponse = `{
"status": "success",
"data": {
"updates": [
{
"stack": "engine",
"has_update": false,
"resolution": "not_tracked",
"files": [
{
"component": "engine",
"os": "linux",
"arch": "arm64",
"action": "unknown",
"reason": "component_not_tracked",
"pinned": false,
"package_name": "",
"version": "",
"file_hash": ""
}
]
},
{
"stack": "langfuse",
"has_update": false,
"resolution": "no_artifact_for_tag",
"images": [
{
"component": "postgres",
"os": "linux",
"arch": "arm64",
"action": "unknown",
"reason": "tag_not_published",
"pinned": false,
"repository": "postgres",
"tag": "15",
"pull_reference": "",
"image_hash": ""
}
]
},
{
"stack": "pentagi",
"has_update": true,
"resolution": "release",
"current_version": "2.0.0",
"current_version_mixed": true,
"latest_version": "2.1.0",
"changelog": "fixes",
"release_notes": "notes",
"releases": [
{
"version": "2.0.5",
"is_stable": true,
"released_at": "2026-01-02T03:04:05Z",
"changelog": "hotfix",
"release_notes": "hotfix notes"
},
{
"version": "2.1.0",
"is_stable": true,
"changelog": "fixes",
"release_notes": "notes"
}
],
"releases_truncated": true,
"images": [
{
"component": "pentagi",
"os": "linux",
"arch": "arm64",
"action": "upgrade",
"pinned": true,
"release_version": "2.1.0",
"repository": "vxcontrol/pentagi",
"tag": "latest",
"pull_reference": "vxcontrol/pentagi:latest",
"image_hash": "1111111111111111111111111111111111111111111111111111111111111111",
"config_hash": "2222222222222222222222222222222222222222222222222222222222222222",
"index_hash": "3333333333333333333333333333333333333333333333333333333333333333"
}
],
"files": [
{
"component": "installer",
"os": "darwin",
"arch": "arm64",
"action": "current",
"pinned": false,
"release_version": "2.0.0",
"package_name": "installer",
"version": "2.0.0",
"file_hash": "4444444444444444444444444444444444444444444444444444444444444444"
}
]
}
]
}
}`
func TestCheckUpdatesRequestMatchesTheGoldenWireForm(t *testing.T) {
imageHash := "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
pinnedHash := strings.Repeat("a", 64)
version := "2.0.0-87ac00f"
engineVersion := "1.0.0"
fileHash := "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210"
request := CheckUpdatesRequest{
InstallerVersion: "2.0.0-87ac00f",
InstallerOS: OSTypeLinux,
InstallerArch: ArchTypeARM64,
Strategy: UpdateStrategyStable,
Images: []ImageComponentInfo{
{
Component: ComponentTypePentagi,
Status: ComponentStatusRunning,
OS: OSTypeLinux,
Arch: ArchTypeARM64,
Repository: "vxcontrol/pentagi",
Tag: "latest",
ImageHash: &imageHash,
},
// An image pinned to a tag the server does not publish. Reporting it
// is normal, not malformed — it is how the answer `tag_not_published`
// is reached, and dropping it locally would make the component vanish
// from the answer instead of being explained.
{
Component: ComponentTypePostgres,
Status: ComponentStatusRunning,
OS: OSTypeLinux,
Arch: ArchTypeARM64,
Repository: "postgres",
Tag: "15",
ImageHash: &pinnedHash,
},
},
Stacks: []StackInfo{
{Stack: ProductStackLangfuse, Status: StackStatusExternal},
{Stack: ProductStackObservability, Status: StackStatusUnused},
},
Files: []FileComponentInfo{
{
Component: ComponentTypeInstaller,
Status: ComponentStatusInstalled,
OS: OSTypeDarwin,
Arch: ArchTypeARM64,
Version: &version,
FileHash: &fileHash,
},
// A file installed but never verified: a version and no digest. Both
// are optional on the wire for exactly this case.
{
Component: ComponentTypeEngine,
Status: ComponentStatusInstalled,
OS: OSTypeLinux,
Arch: ArchTypeARM64,
Version: &engineVersion,
},
},
// Carried as raw JSON so that whoever collects it need not understand it.
// Pinned here because a passthrough field is exactly the kind that gets
// "helpfully" retyped into a map or a struct one day, and the moment it is,
// every key the retyping does not name disappears without an error.
Info: json.RawMessage(`{"schema":1,"version":"0.9.3","counts":{"users":3}}`),
}
if err := request.Valid(); err != nil {
t.Fatalf("the golden request must validate: %v", err)
}
produced, err := json.Marshal(request)
if err != nil {
t.Fatalf("marshal: %v", err)
}
var got, want any
if err := json.Unmarshal(produced, &got); err != nil {
t.Fatalf("unmarshal produced: %v", err)
}
if err := json.Unmarshal([]byte(GoldenCheckUpdatesRequest), &want); err != nil {
t.Fatalf("unmarshal golden: %v", err)
}
gotJSON, _ := json.Marshal(got)
wantJSON, _ := json.Marshal(want)
if string(gotJSON) != string(wantJSON) {
t.Errorf("wire form drifted\n produced: %s\n golden: %s", gotJSON, wantJSON)
}
}
func TestCheckUpdatesResponseParsesEveryGoldenField(t *testing.T) {
response, err := ParseEnvelope[CheckUpdatesResponse]([]byte(GoldenCheckUpdatesResponse))
if err != nil {
t.Fatalf("parse: %v", err)
}
if len(response.Updates) != 3 {
t.Fatalf("expected 3 updates, got %d", len(response.Updates))
}
// Stacks arrive sorted by name. The order is part of the wire form, and a
// client that indexes into the list — this test does — has to say so.
notTracked, unpublishedTag, upToDate := response.Updates[0], response.Updates[1], response.Updates[2]
if upToDate.Stack != ProductStackPentagi || !upToDate.HasUpdate {
t.Errorf("stack/has_update lost: %+v", upToDate)
}
if upToDate.Resolution != StackResolutionRelease {
t.Errorf("resolution lost: %q", upToDate.Resolution)
}
if upToDate.LatestVersion == nil || *upToDate.LatestVersion != "2.1.0" {
t.Errorf("latest_version lost: %+v", upToDate.LatestVersion)
}
if upToDate.Changelog == nil || upToDate.ReleaseNotes == nil {
t.Error("changelog/release_notes lost — the update screen has nothing to show")
}
if len(upToDate.Images) != 1 {
t.Fatalf("expected 1 image, got %d", len(upToDate.Images))
}
if len(upToDate.Files) != 1 {
t.Fatalf("expected 1 file, got %d", len(upToDate.Files))
}
// The image list is what makes post-update verification possible: without it
// a client cannot tell which digest it was supposed to land on.
image := upToDate.Images[0]
if image.ConfigHash == nil || image.IndexHash == nil || image.ImageHash == "" {
t.Fatalf("all three image identities must survive parsing: %+v", image)
}
if image.Repository == "" || image.Tag == "" {
t.Fatalf("the reference to pull must survive parsing: %+v", image)
}
// PullReference is NOT repository:tag in general — under `stable` it pins a
// more specific tag — so a client that rebuilt it locally would write the
// wrong thing into its compose file.
if image.PullReference != "vxcontrol/pentagi:latest" {
t.Errorf("pull_reference lost: %q", image.PullReference)
}
if image.Action != ComponentActionUpgrade || !image.Pinned {
t.Errorf("action/pinned lost: %+v", image)
}
if image.ReleaseVersion == nil || *image.ReleaseVersion != "2.1.0" {
t.Errorf("release_version lost: %+v", image.ReleaseVersion)
}
for _, digest := range []string{image.ImageHash, *image.ConfigHash, *image.IndexHash} {
if !image.CarriesDigest(digest) {
t.Errorf("CarriesDigest must recognise %s", digest)
}
}
if image.CarriesDigest(strings.Repeat("9", 64)) {
t.Error("CarriesDigest must not match an unrelated digest")
}
// A file carries no reference and no digest triple — its identity is the
// package, the version and the hash.
file := upToDate.Files[0]
if file.PackageName == "" || file.Version == "" || file.FileHash == "" {
t.Fatalf("file identity fields lost: %+v", file)
}
if file.Action != ComponentActionCurrent {
t.Errorf("a file's action lost: %q", file.Action)
}
if file.ReleaseVersion == nil || *file.ReleaseVersion != "2.0.0" {
t.Errorf("a file's release_version lost: %+v", file.ReleaseVersion)
}
// The crossed releases are what the update-overview screen renders, and the
// two flags beside them are `omitempty` booleans — absent when false, so a
// fixture that left them unset would pin nothing at all.
if !upToDate.CurrentVersionMixed {
t.Error("current_version_mixed lost — the screen cannot tell a uniform installation from a mixed one")
}
if !upToDate.ReleasesTruncated {
t.Error("releases_truncated lost — the screen cannot say the history is incomplete")
}
if len(upToDate.Releases) != 2 {
t.Fatalf("expected 2 crossed releases, got %d", len(upToDate.Releases))
}
if upToDate.Releases[1].Version != "2.1.0" {
t.Errorf("the LAST crossed release is the target: %q", upToDate.Releases[1].Version)
}
if upToDate.Releases[0].Changelog != "hotfix" || upToDate.Releases[0].ReleaseNotes != "hotfix notes" {
t.Errorf("a crossed release must carry its OWN text, not the target's: %+v", upToDate.Releases[0])
}
// released_at is absent for a release nobody pressed Publish on, and a client
// that treats absence as an error would refuse to render half the list.
if upToDate.Releases[0].ReleasedAt == nil || upToDate.Releases[1].ReleasedAt != nil {
t.Errorf("released_at is present exactly when the release was published: %+v", upToDate.Releases)
}
// An unresolved component comes back with a REASON and no artefact. Dropping
// the component instead would be byte-identical to "you are up to date", and
// dropping only the reason leaves a client that must say why it cannot act
// with nothing to say.
if len(notTracked.Files) != 1 {
t.Fatalf("an unresolved component must still be answered: %+v", notTracked)
}
unknownFile := notTracked.Files[0]
if unknownFile.Action != ComponentActionUnknown {
t.Errorf("a file's unknown action lost: %q", unknownFile.Action)
}
if unknownFile.Reason == nil || *unknownFile.Reason != ReasonComponentNotTracked {
t.Fatalf("a file's reason lost: %+v", unknownFile.Reason)
}
if notTracked.Resolution != StackResolutionNotTracked {
t.Errorf("the stack's resolution lost: %q", notTracked.Resolution)
}
if notTracked.HasUpdate {
t.Error("a stack whose component could not be resolved has no update to offer")
}
if len(unpublishedTag.Images) != 1 {
t.Fatalf("an unresolved image must still be answered: %+v", unpublishedTag)
}
unknownImage := unpublishedTag.Images[0]
if unknownImage.Reason == nil || *unknownImage.Reason != ReasonTagNotPublished {
t.Fatalf("an image's reason lost: %+v", unknownImage.Reason)
}
// The echoed reference is what makes the reason readable without the request
// beside it: "tag_not_published" alone does not say WHICH tag.
if unknownImage.Repository != "postgres" || unknownImage.Tag != "15" {
t.Errorf("the reported reference must be echoed back: %+v", unknownImage)
}
if unknownImage.PullReference != "" || unknownImage.ImageHash != "" {
t.Errorf("an unresolved image must name no artefact: %+v", unknownImage)
}
if unpublishedTag.Resolution != StackResolutionNoArtifactForTag {
t.Errorf("the stack's resolution lost: %q", unpublishedTag.Resolution)
}
}
// TestCheckUpdatesResponseSurvivesARoundTrip is the half of drift detection that
// field-by-field assertions cannot cover.
//
// Parsing proves this package can READ a field. It says nothing about a field
// the server sends and this package has no home for: an unknown key is silently
// discarded, so a rename on the server side leaves every assertion above green
// while the value never arrives. Re-marshalling what was parsed and comparing it
// against the golden bytes turns that silence into a failure — and it does so
// for every field at once, including ones added after this test was written.
func TestCheckUpdatesResponseSurvivesARoundTrip(t *testing.T) {
response, err := ParseEnvelope[CheckUpdatesResponse]([]byte(GoldenCheckUpdatesResponse))
if err != nil {
t.Fatalf("parse: %v", err)
}
if err := response.Valid(); err != nil {
t.Fatalf("the golden answer must be one a client accepts: %v", err)
}
reproduced, err := json.Marshal(response)
if err != nil {
t.Fatalf("marshal: %v", err)
}
var envelope struct {
Data json.RawMessage `json:"data"`
}
if err := json.Unmarshal([]byte(GoldenCheckUpdatesResponse), &envelope); err != nil {
t.Fatalf("unmarshal golden: %v", err)
}
var got, want any
if err := json.Unmarshal(reproduced, &got); err != nil {
t.Fatalf("unmarshal reproduced: %v", err)
}
if err := json.Unmarshal(envelope.Data, &want); err != nil {
t.Fatalf("unmarshal golden payload: %v", err)
}
gotJSON, _ := json.Marshal(got)
wantJSON, _ := json.Marshal(want)
if string(gotJSON) != string(wantJSON) {
t.Errorf("a field did not survive the round trip — this package cannot hold it\n"+
" read back: %s\n golden: %s", gotJSON, wantJSON)
}
}
// TestTheAnswerVocabulariesAreSpelledTheSameOnBothSides.
//
// The golden forms pin the fields; they cannot pin the VALUES, because one
// fixture carries one value per field and the vocabularies have four and five
// members. That gap is not theoretical: the server spelled `downgrade` as
// `rollback` for a while and nothing was red on either side. This package
// REJECTS an unknown action, so the first installation offered a deliberate move
// onto a release would have failed its whole update check.
//
// The server pins the same list (pkg/server/models/public_contract_test.go); if
// you change one, change the other. The rejected column matters as much as the
// accepted one: a vocabulary that accepts everything pins nothing.
func TestTheAnswerVocabulariesAreSpelledTheSameOnBothSides(t *testing.T) {
for name, vocabulary := range map[string]struct {
accepted []string
rejected []string
valid func(string) error
}{
"ComponentAction": {
accepted: []string{"current", "install", "upgrade", "downgrade", "unknown"},
rejected: []string{"rollback", "none", "update", ""},
valid: func(v string) error { return ComponentAction(v).Valid() },
},
"ComponentReason": {
accepted: []string{
"tag_not_published", "repository_not_tracked",
"component_not_tracked", "no_release_artifact_for_tag",
},
rejected: []string{"not_tracked", "tag_not_found", "unknown", ""},
valid: func(v string) error { return ComponentReason(v).Valid() },
},
"StackResolution": {
accepted: []string{
"release", "channel", "ahead_of_release", "no_artifact_for_tag", "not_tracked",
},
rejected: []string{"ahead", "tag_not_published", "stable", ""},
valid: func(v string) error { return StackResolution(v).Valid() },
},
"StackStatus": {
accepted: []string{"unused", "connected", "installed", "external"},
rejected: []string{"running", "absent", "disabled", ""},
valid: func(v string) error { return StackStatus(v).Valid() },
},
"UpdateStrategy": {
accepted: []string{"nightly", "preview", "stable"},
rejected: []string{"weekly", "latest", "beta", ""},
valid: func(v string) error { return UpdateStrategy(v).Valid() },
},
} {
t.Run(name, func(t *testing.T) {
for _, value := range vocabulary.accepted {
if err := vocabulary.valid(value); err != nil {
t.Errorf("%s must accept %q — the server sends or expects it: %v", name, value, err)
}
}
for _, value := range vocabulary.rejected {
if err := vocabulary.valid(value); err == nil {
t.Errorf("%s must reject %q", name, value)
}
}
})
}
}
// TestParsingWithoutTheEnvelopeLosesEverything documents why ParseEnvelope
// exists. Decoding a response body straight into the payload type does not fail
// — it silently yields a zero value, which a client reads as "no updates".
func TestParsingWithoutTheEnvelopeLosesEverything(t *testing.T) {
var naive CheckUpdatesResponse
if err := json.Unmarshal([]byte(GoldenCheckUpdatesResponse), &naive); err != nil {
t.Fatalf("the naive decode is expected to SUCCEED, which is the trap: %v", err)
}
if len(naive.Updates) != 0 {
t.Fatal("test is no longer demonstrating the trap")
}
parsed, err := ParseEnvelope[CheckUpdatesResponse]([]byte(GoldenCheckUpdatesResponse))
if err != nil {
t.Fatalf("parse: %v", err)
}
if len(parsed.Updates) == 0 {
t.Fatal("ParseEnvelope must recover what the naive decode drops")
}
}
func TestParseEnvelopeReportsServerErrors(t *testing.T) {
body := `{"status":"error","code":"Updates.InvalidData","msg":"invalid check updates data"}`
_, err := ParseEnvelope[CheckUpdatesResponse]([]byte(body))
if err == nil {
t.Fatal("an error envelope must not be reported as success")
}
apiErr, ok := err.(*APIError)
if !ok {
t.Fatalf("expected *APIError, got %T", err)
}
if apiErr.Code != "Updates.InvalidData" {
t.Errorf("code lost: %q", apiErr.Code)
}
}
func TestRequestValidationMatchesTheServerRules(t *testing.T) {
base := func() CheckUpdatesRequest {
return CheckUpdatesRequest{
InstallerVersion: "2.0.0",
InstallerOS: OSTypeLinux,
InstallerArch: ArchTypeAMD64,
Strategy: UpdateStrategyStable,
}
}
t.Run("strategy is required and has no default", func(t *testing.T) {
request := base()
request.Strategy = ""
if err := request.Valid(); err == nil {
t.Error("a request without a strategy must not validate")
}
request.Strategy = "weekly"
if err := request.Valid(); err == nil {
t.Error("an unknown strategy must not validate")
}
})
t.Run("installer version follows the server pattern", func(t *testing.T) {
accepted := []string{"2.0.0", "2.0", "2.0.0-87ac00f", "0.0.0"}
for _, version := range accepted {
request := base()
request.InstallerVersion = version
if err := request.Valid(); err != nil {
t.Errorf("%q must be accepted: %v", version, err)
}
}
// A development build reports its branch name, which the contract cannot
// express — normalise it to 0.0.0 before sending.
for _, version := range []string{"develop", "develop-abc1234", "v2.0.0", ""} {
request := base()
request.InstallerVersion = version
if err := request.Valid(); err == nil {
t.Errorf("%q must be rejected", version)
}
}
})
t.Run("digests are bare lowercase hex", func(t *testing.T) {
bare := "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
prefixed := "sha256:" + bare
upper := strings.ToUpper(bare)
for name, digest := range map[string]string{"bare": bare, "prefixed": prefixed, "uppercase": upper} {
request := base()
request.Images = []ImageComponentInfo{{
Component: ComponentTypePentagi,
Status: ComponentStatusRunning,
OS: OSTypeLinux,
Arch: ArchTypeAMD64,
Repository: "vxcontrol/pentagi",
Tag: "latest",
ImageHash: &digest,
}}
err := request.Valid()
if name == "bare" && err != nil {
t.Errorf("a bare digest must be accepted: %v", err)
}
if name != "bare" && err == nil {
t.Errorf("a %s digest must be rejected — strip the prefix and lowercase it", name)
}
}
})
t.Run("component os and arch are required", func(t *testing.T) {
request := base()
request.Images = []ImageComponentInfo{{
Component: ComponentTypePentagi,
Status: ComponentStatusRunning,
Repository: "vxcontrol/pentagi",
Tag: "latest",
}}
if err := request.Valid(); err == nil {
t.Error("a component without os/arch must not validate")
}
})
t.Run("an image must name the reference it follows", func(t *testing.T) {
for _, missing := range []string{"repository", "tag"} {
image := ImageComponentInfo{
Component: ComponentTypePentagi,
Status: ComponentStatusRunning,
OS: OSTypeLinux,
Arch: ArchTypeAMD64,
Repository: "vxcontrol/pentagi",
Tag: "latest",
}
switch missing {
case "repository":
image.Repository = ""
case "tag":
image.Tag = ""
}
request := base()
request.Images = []ImageComponentInfo{image}
if err := request.Valid(); err == nil {
t.Errorf("an image without a %s must not validate: an update is the newest"+
" image under the reference the installation follows, and without the"+
" reference the question has no answer", missing)
}
}
})
t.Run("at most 30 artefacts across BOTH lists", func(t *testing.T) {
image := ImageComponentInfo{
Component: ComponentTypePentagi,
Status: ComponentStatusRunning,
OS: OSTypeLinux,
Arch: ArchTypeAMD64,
Repository: "vxcontrol/pentagi",
Tag: "latest",
}
file := FileComponentInfo{
Component: ComponentTypeInstaller,
Status: ComponentStatusRunning,
OS: OSTypeLinux,
Arch: ArchTypeAMD64,
}
request := base()
for i := 0; i < 30; i++ {
request.Images = append(request.Images, image)
}
if err := request.Valid(); err != nil {
t.Errorf("30 artefacts must be accepted: %v", err)
}
request.Images = append(request.Images, image)
if err := request.Valid(); err == nil {
t.Error("31 artefacts must be rejected")
}
// The limit counts the two lists TOGETHER. Bounding each separately
// would let a caller send twice the limit by splitting the payload,
// which is not a limit at all.
split := base()
for range 30 {
split.Images = append(split.Images, image)
}
for range 20 {
split.Files = append(split.Files, file)
}
if err := split.Valid(); err == nil {
t.Error("30 images plus 20 files is 50 artefacts and must be rejected")
}
})
}
func TestEveryComponentBelongsToAStack(t *testing.T) {
// The vocabulary itself, not a copy of it. A copy would have to be edited
// alongside every addition and would go stale silently between them; what is
// under test here is the STACK MAPPING, and it is the thing that must cover
// whatever the vocabulary happens to contain. The vocabulary is separately
// pinned against the server's in component_pairing_test.go.
all := AllComponentTypes
for _, component := range all {
if err := component.Valid(); err != nil {
t.Errorf("%s must be a valid component: %v", component, err)
}
stack, ok := ComponentToStackMapping[component]
if !ok {
t.Errorf("%s has no stack — its answer would be attributed to the wrong one", component)
continue
}
if err := stack.Valid(); err != nil {
t.Errorf("%s maps to an invalid stack %q", component, stack)
}
}
if len(ComponentToStackMapping) != len(all) {
t.Errorf("the mapping has %d entries but %d components are known",
len(ComponentToStackMapping), len(all))
}
}
// GoldenProductUpdateRequest is the exact wire form this package produces when a
// running product tells the update service what it currently is. The server side
// keeps a copy of this literal and asserts its own model accepts it, so a field
// renamed on either side turns one of the two tests red instead of silently
// producing requests nobody can parse.
//
// `info` is deliberately a shape this package does not model. It is versioned by
// the `schema` value inside it, so the contract carries it and never interprets
// it — and this literal is here to prove that a document the SDK knows nothing
// about survives the trip.
const GoldenProductUpdateRequest = `{
"schema": 1,
"version": "0.9.3",
"os": "linux",
"arch": "amd64",
"binary_hash": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
"info": {
"schema": 1,
"version": "0.9.3",
"develop": false,
"counts": {"users": 3, "flows": 12},
"providers": [{"type": "openai", "count": 2}]
}
}`
func TestProductUpdateRequestMatchesTheGoldenWireForm(t *testing.T) {
var request ProductUpdateRequest
decoder := json.NewDecoder(strings.NewReader(GoldenProductUpdateRequest))
// A field this package does not know is a contract break, not something to
// ignore quietly: the sender believed it was telling the service something.
decoder.DisallowUnknownFields()
if err := decoder.Decode(&request); err != nil {
t.Fatalf("the golden request must decode into the model: %v", err)
}
if err := request.Valid(); err != nil {
t.Fatalf("the golden request must pass validation: %v", err)
}
if request.Schema != 1 {
t.Errorf("schema lost: %d", request.Schema)
}
if request.Version != "0.9.3" {
t.Errorf("version lost: %q", request.Version)
}
if request.OS != OSTypeLinux || request.Arch != ArchTypeAMD64 {
t.Errorf("platform lost: %s/%s", request.OS, request.Arch)
}
if len(request.BinaryHash) != 64 {
t.Errorf("binary hash lost: %q", request.BinaryHash)
}
// Carried byte for byte, not reshaped. The whole point of the field is that
// the party sending it need not understand it, so anything that re-encodes it
// on the way — a map, a typed struct, a normalising decoder — would silently
// drop whatever this package does not name.
if !json.Valid(request.Info) {
t.Fatalf("the state document must arrive as usable JSON: %q", string(request.Info))
}
if !strings.Contains(string(request.Info), `"schema": 1`) {
t.Errorf("the document must survive verbatim, including the version that selects its parser: %q",
string(request.Info))
}
// And back out again with its content intact: a client builds this from a
// document it did not author either.
//
// Content, not bytes. json.Marshal COMPACTS a json.RawMessage on the way out,
// so an indented document comes back without its whitespace — which is why
// anything comparing stored documents has to compact both sides first rather
// than diff the strings.
encoded, err := json.Marshal(request)
if err != nil {
t.Fatalf("the request must re-encode: %v", err)
}
var round ProductUpdateRequest
if err := json.Unmarshal(encoded, &round); err != nil {
t.Fatalf("the re-encoded request must decode: %v", err)
}
var before, after any
if err := json.Unmarshal(request.Info, &before); err != nil {
t.Fatalf("the original document must parse: %v", err)
}
if err := json.Unmarshal(round.Info, &after); err != nil {
t.Fatalf("the round-tripped document must parse: %v", err)
}
if !reflect.DeepEqual(before, after) {
t.Errorf("the state document did not survive a round trip:\n before %s\n after %s",
request.Info, round.Info)
}
}
// The bound belongs to the contract, not to whoever happens to send the request:
// the endpoint reads the body with no size limit of its own.
func TestProductUpdateRequestRejectsAnOversizedDocument(t *testing.T) {
oversized := ProductUpdateRequest{
Schema: 1,
Version: "0.9.3",
OS: OSTypeLinux,
Arch: ArchTypeAMD64,
Info: json.RawMessage(`{"pad":"` + strings.Repeat("x", MaxProductInfoBytes) + `"}`),
}
if err := oversized.Valid(); err == nil {
t.Fatal("a document past MaxProductInfoBytes must not validate")
}
fits := oversized
fits.Info = json.RawMessage(`{"schema":1}`)
if err := fits.Valid(); err != nil {
t.Fatalf("an ordinary document must validate: %v", err)
}
}
// A development build is named after its branch, and this field is validated as
// a version — so the sender has to normalise it or lose the whole request over a
// field that is decoration. Pinned here because the rule lives on this side.
func TestProductUpdateRequestRequiresASemanticVersion(t *testing.T) {
request := ProductUpdateRequest{
Schema: 1,
Version: "develop",
OS: OSTypeLinux,
Arch: ArchTypeAMD64,
Info: json.RawMessage(`{"schema":1}`),
}
if err := request.Valid(); err == nil {
t.Fatal("a branch name must not pass as a version")
}
request.Version = "0.0.0"
if err := request.Valid(); err != nil {
t.Fatalf("the normalised placeholder must pass: %v", err)
}
}