mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-26 20:24:16 +00:00
1409 lines
38 KiB
Go
1409 lines
38 KiB
Go
package atproto
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/bluesky-social/indigo/atproto/syntax"
|
|
)
|
|
|
|
// TestDIDValidationParity asserts that the set of DIDs we accept/reject as
|
|
// "looks like a DID" matches the syntax.ParseDID behavior we're switching to.
|
|
// Includes the local-dev shapes we must not break.
|
|
func TestDIDValidationParity(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
s string
|
|
want bool
|
|
}{
|
|
{"valid did:web", "did:web:example.com", true},
|
|
{"valid did:web local dev percent-encoded port", "did:web:127.0.0.1%3A8000", true},
|
|
{"valid did:web localhost percent-encoded port", "did:web:localhost%3A8080", true},
|
|
{"valid did:plc short test fixture", "did:plc:abc123", true},
|
|
{"valid did:plc real 24-char", "did:plc:pddp4xt5lgnv2qsegbzzs4xg", true},
|
|
{"valid did:key", "did:key:z6Mkfriq", true},
|
|
{"reject empty method body", "did:plc:", false},
|
|
{"reject single-segment did", "did:", false},
|
|
{"reject no did prefix", "https://example.com", false},
|
|
{"reject plain text", "hello world", false},
|
|
{"reject empty", "", false},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
_, err := syntax.ParseDID(tt.s)
|
|
got := err == nil
|
|
if got != tt.want {
|
|
t.Errorf("syntax.ParseDID(%q) accepted=%v, want %v (err=%v)", tt.s, got, tt.want, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestNewManifestRecord(t *testing.T) {
|
|
validOCIManifest := `{
|
|
"schemaVersion": 2,
|
|
"mediaType": "application/vnd.oci.image.manifest.v1+json",
|
|
"config": {
|
|
"mediaType": "application/vnd.oci.image.config.v1+json",
|
|
"digest": "sha256:config123",
|
|
"size": 1234
|
|
},
|
|
"layers": [
|
|
{
|
|
"mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
|
|
"digest": "sha256:layer1",
|
|
"size": 5678
|
|
},
|
|
{
|
|
"mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
|
|
"digest": "sha256:layer2",
|
|
"size": 9012
|
|
}
|
|
],
|
|
"annotations": {
|
|
"org.opencontainers.image.created": "2025-01-01T00:00:00Z"
|
|
}
|
|
}`
|
|
|
|
manifestWithSubject := `{
|
|
"schemaVersion": 2,
|
|
"mediaType": "application/vnd.oci.image.manifest.v1+json",
|
|
"config": {
|
|
"mediaType": "application/vnd.oci.image.config.v1+json",
|
|
"digest": "sha256:config123",
|
|
"size": 1234
|
|
},
|
|
"layers": [],
|
|
"subject": {
|
|
"mediaType": "application/vnd.oci.image.manifest.v1+json",
|
|
"digest": "sha256:subject123",
|
|
"size": 4321
|
|
}
|
|
}`
|
|
|
|
manifestList := `{
|
|
"schemaVersion": 2,
|
|
"mediaType": "application/vnd.oci.image.index.v1+json",
|
|
"manifests": [
|
|
{
|
|
"mediaType": "application/vnd.oci.image.manifest.v1+json",
|
|
"digest": "sha256:amd64manifest",
|
|
"size": 1000,
|
|
"platform": {
|
|
"architecture": "amd64",
|
|
"os": "linux"
|
|
}
|
|
},
|
|
{
|
|
"mediaType": "application/vnd.oci.image.manifest.v1+json",
|
|
"digest": "sha256:arm64manifest",
|
|
"size": 1100,
|
|
"platform": {
|
|
"architecture": "arm64",
|
|
"os": "linux",
|
|
"variant": "v8"
|
|
}
|
|
}
|
|
]
|
|
}`
|
|
|
|
invalidBothFields := `{
|
|
"schemaVersion": 2,
|
|
"mediaType": "application/vnd.oci.image.index.v1+json",
|
|
"config": {
|
|
"mediaType": "application/vnd.oci.image.config.v1+json",
|
|
"digest": "sha256:config123",
|
|
"size": 1234
|
|
},
|
|
"layers": [],
|
|
"manifests": [
|
|
{
|
|
"mediaType": "application/vnd.oci.image.manifest.v1+json",
|
|
"digest": "sha256:amd64manifest",
|
|
"size": 1000
|
|
}
|
|
]
|
|
}`
|
|
|
|
invalidNoFields := `{
|
|
"schemaVersion": 2,
|
|
"mediaType": "application/vnd.oci.image.manifest.v1+json"
|
|
}`
|
|
|
|
tests := []struct {
|
|
name string
|
|
repository string
|
|
digest string
|
|
ociManifest string
|
|
wantErr bool
|
|
checkFunc func(*testing.T, *ManifestRecord)
|
|
}{
|
|
{
|
|
name: "valid OCI manifest",
|
|
repository: "myapp",
|
|
digest: "sha256:abc123",
|
|
ociManifest: validOCIManifest,
|
|
wantErr: false,
|
|
checkFunc: func(t *testing.T, record *ManifestRecord) {
|
|
if record.Type != ManifestCollection {
|
|
t.Errorf("Type = %v, want %v", record.Type, ManifestCollection)
|
|
}
|
|
if record.Repository != "myapp" {
|
|
t.Errorf("Repository = %v, want myapp", record.Repository)
|
|
}
|
|
if record.Digest != "sha256:abc123" {
|
|
t.Errorf("Digest = %v, want sha256:abc123", record.Digest)
|
|
}
|
|
if record.SchemaVersion != 2 {
|
|
t.Errorf("SchemaVersion = %v, want 2", record.SchemaVersion)
|
|
}
|
|
if record.MediaType != "application/vnd.oci.image.manifest.v1+json" {
|
|
t.Errorf("MediaType = %v, want application/vnd.oci.image.manifest.v1+json", record.MediaType)
|
|
}
|
|
if record.Config.Digest != "sha256:config123" {
|
|
t.Errorf("Config.Digest = %v, want sha256:config123", record.Config.Digest)
|
|
}
|
|
if record.Config.Size != 1234 {
|
|
t.Errorf("Config.Size = %v, want 1234", record.Config.Size)
|
|
}
|
|
if len(record.Layers) != 2 {
|
|
t.Fatalf("len(Layers) = %v, want 2", len(record.Layers))
|
|
}
|
|
if record.Layers[0].Digest != "sha256:layer1" {
|
|
t.Errorf("Layers[0].Digest = %v, want sha256:layer1", record.Layers[0].Digest)
|
|
}
|
|
if record.Layers[1].Digest != "sha256:layer2" {
|
|
t.Errorf("Layers[1].Digest = %v, want sha256:layer2", record.Layers[1].Digest)
|
|
}
|
|
if record.Annotations["org.opencontainers.image.created"] != "2025-01-01T00:00:00Z" {
|
|
t.Errorf("Annotations missing expected key")
|
|
}
|
|
if record.CreatedAt.IsZero() {
|
|
t.Error("CreatedAt should not be zero")
|
|
}
|
|
if record.Subject != nil {
|
|
t.Error("Subject should be nil")
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "manifest with subject",
|
|
repository: "myapp",
|
|
digest: "sha256:abc123",
|
|
ociManifest: manifestWithSubject,
|
|
wantErr: false,
|
|
checkFunc: func(t *testing.T, record *ManifestRecord) {
|
|
if record.Subject == nil {
|
|
t.Fatal("Subject should not be nil")
|
|
}
|
|
if record.Subject.Digest != "sha256:subject123" {
|
|
t.Errorf("Subject.Digest = %v, want sha256:subject123", record.Subject.Digest)
|
|
}
|
|
if record.Subject.Size != 4321 {
|
|
t.Errorf("Subject.Size = %v, want 4321", record.Subject.Size)
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "invalid JSON",
|
|
repository: "myapp",
|
|
digest: "sha256:abc123",
|
|
ociManifest: "not valid json",
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "invalid config JSON",
|
|
repository: "myapp",
|
|
digest: "sha256:abc123",
|
|
ociManifest: `{"schemaVersion": 2, "mediaType": "test", "config": "not-an-object", "layers": []}`,
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "valid manifest list (multi-arch)",
|
|
repository: "myapp",
|
|
digest: "sha256:multiarch",
|
|
ociManifest: manifestList,
|
|
wantErr: false,
|
|
checkFunc: func(t *testing.T, record *ManifestRecord) {
|
|
if record.MediaType != "application/vnd.oci.image.index.v1+json" {
|
|
t.Errorf("MediaType = %v, want application/vnd.oci.image.index.v1+json", record.MediaType)
|
|
}
|
|
if record.Config != nil {
|
|
t.Error("Config should be nil for manifest list")
|
|
}
|
|
if len(record.Layers) != 0 {
|
|
t.Errorf("Layers should be empty for manifest list, got %d", len(record.Layers))
|
|
}
|
|
if len(record.Manifests) != 2 {
|
|
t.Fatalf("Manifests should have 2 entries, got %d", len(record.Manifests))
|
|
}
|
|
|
|
// Check first manifest (amd64)
|
|
if record.Manifests[0].Digest != "sha256:amd64manifest" {
|
|
t.Errorf("Manifests[0].Digest = %v, want sha256:amd64manifest", record.Manifests[0].Digest)
|
|
}
|
|
if record.Manifests[0].Size != 1000 {
|
|
t.Errorf("Manifests[0].Size = %v, want 1000", record.Manifests[0].Size)
|
|
}
|
|
if record.Manifests[0].Platform == nil {
|
|
t.Fatal("Manifests[0].Platform should not be nil")
|
|
}
|
|
if record.Manifests[0].Platform.Architecture != "amd64" {
|
|
t.Errorf("Platform.Architecture = %v, want amd64", record.Manifests[0].Platform.Architecture)
|
|
}
|
|
if record.Manifests[0].Platform.OS != "linux" {
|
|
t.Errorf("Platform.OS = %v, want linux", record.Manifests[0].Platform.OS)
|
|
}
|
|
|
|
// Check second manifest (arm64)
|
|
if record.Manifests[1].Digest != "sha256:arm64manifest" {
|
|
t.Errorf("Manifests[1].Digest = %v, want sha256:arm64manifest", record.Manifests[1].Digest)
|
|
}
|
|
if record.Manifests[1].Platform.Architecture != "arm64" {
|
|
t.Errorf("Platform.Architecture = %v, want arm64", record.Manifests[1].Platform.Architecture)
|
|
}
|
|
if record.Manifests[1].Platform.Variant != "v8" {
|
|
t.Errorf("Platform.Variant = %v, want v8", record.Manifests[1].Platform.Variant)
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "invalid: both image and index fields",
|
|
repository: "myapp",
|
|
digest: "sha256:invalid",
|
|
ociManifest: invalidBothFields,
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "invalid: neither image nor index fields",
|
|
repository: "myapp",
|
|
digest: "sha256:invalid",
|
|
ociManifest: invalidNoFields,
|
|
wantErr: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got, err := NewManifestRecord(tt.repository, tt.digest, []byte(tt.ociManifest))
|
|
if (err != nil) != tt.wantErr {
|
|
t.Errorf("NewManifestRecord() error = %v, wantErr %v", err, tt.wantErr)
|
|
return
|
|
}
|
|
|
|
if !tt.wantErr && tt.checkFunc != nil {
|
|
tt.checkFunc(t, got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestNewTagRecord(t *testing.T) {
|
|
did := "did:plc:test123"
|
|
before := time.Now()
|
|
record := NewTagRecord(did, "myapp", "latest", "sha256:abc123", "application/vnd.oci.image.manifest.v1+json")
|
|
after := time.Now()
|
|
|
|
if record.Type != TagCollection {
|
|
t.Errorf("Type = %v, want %v", record.Type, TagCollection)
|
|
}
|
|
|
|
if record.Repository != "myapp" {
|
|
t.Errorf("Repository = %v, want myapp", record.Repository)
|
|
}
|
|
|
|
if record.Tag != "latest" {
|
|
t.Errorf("Tag = %v, want latest", record.Tag)
|
|
}
|
|
|
|
// New records should have manifest field (AT-URI)
|
|
expectedURI := "at://did:plc:test123/io.atcr.manifest/abc123"
|
|
if record.Manifest != expectedURI {
|
|
t.Errorf("Manifest = %v, want %v", record.Manifest, expectedURI)
|
|
}
|
|
|
|
// New records should have media type
|
|
if record.MediaType != "application/vnd.oci.image.manifest.v1+json" {
|
|
t.Errorf("MediaType = %v, want application/vnd.oci.image.manifest.v1+json", record.MediaType)
|
|
}
|
|
|
|
// New records should NOT have manifestDigest field
|
|
if record.ManifestDigest != "" {
|
|
t.Errorf("ManifestDigest should be empty for new records, got %v", record.ManifestDigest)
|
|
}
|
|
|
|
if record.UpdatedAt.Before(before) || record.UpdatedAt.After(after) {
|
|
t.Errorf("UpdatedAt = %v, want between %v and %v", record.UpdatedAt, before, after)
|
|
}
|
|
}
|
|
|
|
func TestBuildManifestURI(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
did string
|
|
manifestDigest string
|
|
want string
|
|
}{
|
|
{
|
|
name: "standard digest",
|
|
did: "did:plc:abc123",
|
|
manifestDigest: "sha256:def456",
|
|
want: "at://did:plc:abc123/io.atcr.manifest/def456",
|
|
},
|
|
{
|
|
name: "long digest",
|
|
did: "did:web:hold.example.com",
|
|
manifestDigest: "sha256:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789",
|
|
want: "at://did:web:hold.example.com/io.atcr.manifest/abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := BuildManifestURI(tt.did, tt.manifestDigest)
|
|
if got != tt.want {
|
|
t.Errorf("BuildManifestURI() = %v, want %v", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestParseManifestURI(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
manifestURI string
|
|
want string
|
|
wantErr bool
|
|
}{
|
|
{
|
|
name: "valid URI",
|
|
manifestURI: "at://did:plc:abc123/io.atcr.manifest/def456",
|
|
want: "sha256:def456",
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "valid URI with did:web",
|
|
manifestURI: "at://did:web:hold.example.com/io.atcr.manifest/xyz789",
|
|
want: "sha256:xyz789",
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "invalid prefix",
|
|
manifestURI: "https://example.com/manifest",
|
|
want: "",
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "wrong collection",
|
|
manifestURI: "at://did:plc:abc123/io.atcr.tag/def456",
|
|
want: "",
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "too few parts",
|
|
manifestURI: "at://did:plc:abc123/io.atcr.manifest",
|
|
want: "",
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "too many parts",
|
|
manifestURI: "at://did:plc:abc123/io.atcr.manifest/def456/extra",
|
|
want: "",
|
|
wantErr: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got, err := ParseManifestURI(tt.manifestURI)
|
|
if (err != nil) != tt.wantErr {
|
|
t.Errorf("ParseManifestURI() error = %v, wantErr %v", err, tt.wantErr)
|
|
return
|
|
}
|
|
if got != tt.want {
|
|
t.Errorf("ParseManifestURI() = %v, want %v", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestTagRecord_GetManifestDigest(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
record TagRecord
|
|
want string
|
|
wantErr bool
|
|
}{
|
|
{
|
|
name: "new record with manifest field",
|
|
record: TagRecord{
|
|
Manifest: "at://did:plc:test123/io.atcr.manifest/abc123",
|
|
},
|
|
want: "sha256:abc123",
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "old record with manifestDigest field",
|
|
record: TagRecord{
|
|
ManifestDigest: "sha256:def456",
|
|
},
|
|
want: "sha256:def456",
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "prefers manifest over manifestDigest",
|
|
record: TagRecord{
|
|
Manifest: "at://did:plc:test123/io.atcr.manifest/abc123",
|
|
ManifestDigest: "sha256:def456",
|
|
},
|
|
want: "sha256:abc123",
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "no fields set",
|
|
record: TagRecord{},
|
|
want: "",
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "invalid manifest URI",
|
|
record: TagRecord{
|
|
Manifest: "invalid-uri",
|
|
},
|
|
want: "",
|
|
wantErr: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got, err := tt.record.GetManifestDigest()
|
|
if (err != nil) != tt.wantErr {
|
|
t.Errorf("GetManifestDigest() error = %v, wantErr %v", err, tt.wantErr)
|
|
return
|
|
}
|
|
if got != tt.want {
|
|
t.Errorf("GetManifestDigest() = %v, want %v", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestNewSailorProfileRecord(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
defaultHold string
|
|
}{
|
|
{
|
|
name: "with default hold DID",
|
|
defaultHold: "did:web:hold01.atcr.io",
|
|
},
|
|
{
|
|
name: "with default hold URL",
|
|
defaultHold: "https://hold01.atcr.io",
|
|
},
|
|
{
|
|
name: "empty default hold",
|
|
defaultHold: "",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
before := time.Now()
|
|
record := NewSailorProfileRecord(tt.defaultHold)
|
|
after := time.Now()
|
|
|
|
if record.Type != SailorProfileCollection {
|
|
t.Errorf("Type = %v, want %v", record.Type, SailorProfileCollection)
|
|
}
|
|
|
|
if record.DefaultHold != tt.defaultHold {
|
|
t.Errorf("DefaultHold = %v, want %v", record.DefaultHold, tt.defaultHold)
|
|
}
|
|
|
|
if record.CreatedAt.Before(before) || record.CreatedAt.After(after) {
|
|
t.Errorf("CreatedAt = %v, want between %v and %v", record.CreatedAt, before, after)
|
|
}
|
|
|
|
if record.UpdatedAt.Before(before) || record.UpdatedAt.After(after) {
|
|
t.Errorf("UpdatedAt = %v, want between %v and %v", record.UpdatedAt, before, after)
|
|
}
|
|
|
|
// CreatedAt and UpdatedAt should be equal for new records
|
|
if !record.CreatedAt.Equal(record.UpdatedAt) {
|
|
t.Errorf("CreatedAt (%v) != UpdatedAt (%v)", record.CreatedAt, record.UpdatedAt)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestNewStarRecord(t *testing.T) {
|
|
before := time.Now()
|
|
record := NewStarRecord("did:plc:alice123", "myapp")
|
|
after := time.Now()
|
|
|
|
if record.Type != StarCollection {
|
|
t.Errorf("Type = %v, want %v", record.Type, StarCollection)
|
|
}
|
|
|
|
expectedSubject := "at://did:plc:alice123/io.atcr.repo.page/myapp"
|
|
if record.Subject != expectedSubject {
|
|
t.Errorf("Subject = %v, want %v", record.Subject, expectedSubject)
|
|
}
|
|
|
|
if record.CreatedAt.Before(before) || record.CreatedAt.After(after) {
|
|
t.Errorf("CreatedAt = %v, want between %v and %v", record.CreatedAt, before, after)
|
|
}
|
|
}
|
|
|
|
func TestStarRecordKey(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
ownerDID string
|
|
repository string
|
|
wantPrefix string // Expected prefix for validation
|
|
}{
|
|
{
|
|
name: "simple key",
|
|
ownerDID: "did:plc:alice123",
|
|
repository: "myapp",
|
|
},
|
|
{
|
|
name: "long DID and repo",
|
|
ownerDID: "did:plc:abcdefghijklmnopqrstuvwxyz123456",
|
|
repository: "my-very-long-repository-name",
|
|
},
|
|
{
|
|
name: "special characters in repo",
|
|
ownerDID: "did:plc:alice123",
|
|
repository: "my-app_test.v1",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
key := StarRecordKey(tt.ownerDID, tt.repository)
|
|
|
|
// Key should be non-empty
|
|
if key == "" {
|
|
t.Error("StarRecordKey() returned empty string")
|
|
}
|
|
|
|
// Key should be base64 URL-encoded (no padding)
|
|
if strings.Contains(key, "=") {
|
|
t.Errorf("StarRecordKey() = %v, should not contain padding", key)
|
|
}
|
|
|
|
// Should be deterministic
|
|
key2 := StarRecordKey(tt.ownerDID, tt.repository)
|
|
if key != key2 {
|
|
t.Errorf("StarRecordKey() not deterministic: %v != %v", key, key2)
|
|
}
|
|
|
|
// Should be different for different inputs
|
|
differentKey := StarRecordKey(tt.ownerDID, tt.repository+"different")
|
|
if key == differentKey {
|
|
t.Error("StarRecordKey() should be different for different inputs")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestParseStarRecordKey(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
ownerDID string
|
|
repository string
|
|
wantErr bool
|
|
}{
|
|
{
|
|
name: "valid key",
|
|
ownerDID: "did:plc:alice123",
|
|
repository: "myapp",
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "key with special characters",
|
|
ownerDID: "did:plc:alice123",
|
|
repository: "my-app_test.v1",
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "long values",
|
|
ownerDID: "did:plc:abcdefghijklmnopqrstuvwxyz123456",
|
|
repository: "my-very-long-repository-name",
|
|
wantErr: false,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
// Generate key
|
|
key := StarRecordKey(tt.ownerDID, tt.repository)
|
|
|
|
// Parse it back
|
|
gotDID, gotRepo, err := ParseStarRecordKey(key)
|
|
if (err != nil) != tt.wantErr {
|
|
t.Errorf("ParseStarRecordKey() error = %v, wantErr %v", err, tt.wantErr)
|
|
return
|
|
}
|
|
|
|
if !tt.wantErr {
|
|
if gotDID != tt.ownerDID {
|
|
t.Errorf("ParseStarRecordKey() DID = %v, want %v", gotDID, tt.ownerDID)
|
|
}
|
|
if gotRepo != tt.repository {
|
|
t.Errorf("ParseStarRecordKey() repository = %v, want %v", gotRepo, tt.repository)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestParseStarRecordKey_Invalid(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
rkey string
|
|
}{
|
|
{
|
|
name: "invalid base64",
|
|
rkey: "not!!!valid!!!base64",
|
|
},
|
|
{
|
|
name: "no separator - base64 encoded text without slash",
|
|
rkey: "bm9zZXBhcmF0b3I", // base64 of "noseparator" (no "/" in the decoded value)
|
|
},
|
|
{
|
|
name: "empty string",
|
|
rkey: "",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
_, _, err := ParseStarRecordKey(tt.rkey)
|
|
if err == nil {
|
|
t.Error("ParseStarRecordKey() expected error for invalid input")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestManifestRecord_JSONSerialization(t *testing.T) {
|
|
// Create a manifest record
|
|
ociManifest := `{
|
|
"schemaVersion": 2,
|
|
"mediaType": "application/vnd.oci.image.manifest.v1+json",
|
|
"config": {
|
|
"mediaType": "application/vnd.oci.image.config.v1+json",
|
|
"digest": "sha256:config123",
|
|
"size": 1234
|
|
},
|
|
"layers": [
|
|
{
|
|
"mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
|
|
"digest": "sha256:layer1",
|
|
"size": 5678
|
|
}
|
|
]
|
|
}`
|
|
|
|
record, err := NewManifestRecord("myapp", "sha256:abc123", []byte(ociManifest))
|
|
if err != nil {
|
|
t.Fatalf("NewManifestRecord() error = %v", err)
|
|
}
|
|
|
|
// Add hold DID
|
|
record.HoldDID = "did:web:hold01.atcr.io"
|
|
|
|
// Serialize to JSON
|
|
jsonData, err := json.Marshal(record)
|
|
if err != nil {
|
|
t.Fatalf("json.Marshal() error = %v", err)
|
|
}
|
|
|
|
// Deserialize from JSON
|
|
var decoded ManifestRecord
|
|
if err := json.Unmarshal(jsonData, &decoded); err != nil {
|
|
t.Fatalf("json.Unmarshal() error = %v", err)
|
|
}
|
|
|
|
// Verify fields
|
|
if decoded.Type != record.Type {
|
|
t.Errorf("Type = %v, want %v", decoded.Type, record.Type)
|
|
}
|
|
if decoded.Repository != record.Repository {
|
|
t.Errorf("Repository = %v, want %v", decoded.Repository, record.Repository)
|
|
}
|
|
if decoded.Digest != record.Digest {
|
|
t.Errorf("Digest = %v, want %v", decoded.Digest, record.Digest)
|
|
}
|
|
if decoded.HoldDID != record.HoldDID {
|
|
t.Errorf("HoldDID = %v, want %v", decoded.HoldDID, record.HoldDID)
|
|
}
|
|
if decoded.Config.Digest != record.Config.Digest {
|
|
t.Errorf("Config.Digest = %v, want %v", decoded.Config.Digest, record.Config.Digest)
|
|
}
|
|
if len(decoded.Layers) != len(record.Layers) {
|
|
t.Errorf("len(Layers) = %v, want %v", len(decoded.Layers), len(record.Layers))
|
|
}
|
|
}
|
|
|
|
func TestBlobReference_JSONSerialization(t *testing.T) {
|
|
blob := BlobReference{
|
|
MediaType: "application/vnd.oci.image.layer.v1.tar+gzip",
|
|
Digest: "sha256:abc123",
|
|
Size: 12345,
|
|
URLs: []string{"https://s3.example.com/blob"},
|
|
Annotations: map[string]string{
|
|
"key": "value",
|
|
},
|
|
}
|
|
|
|
// Serialize
|
|
jsonData, err := json.Marshal(blob)
|
|
if err != nil {
|
|
t.Fatalf("json.Marshal() error = %v", err)
|
|
}
|
|
|
|
// Deserialize
|
|
var decoded BlobReference
|
|
if err := json.Unmarshal(jsonData, &decoded); err != nil {
|
|
t.Fatalf("json.Unmarshal() error = %v", err)
|
|
}
|
|
|
|
// Verify
|
|
if decoded.MediaType != blob.MediaType {
|
|
t.Errorf("MediaType = %v, want %v", decoded.MediaType, blob.MediaType)
|
|
}
|
|
if decoded.Digest != blob.Digest {
|
|
t.Errorf("Digest = %v, want %v", decoded.Digest, blob.Digest)
|
|
}
|
|
if decoded.Size != blob.Size {
|
|
t.Errorf("Size = %v, want %v", decoded.Size, blob.Size)
|
|
}
|
|
}
|
|
|
|
func TestStarRecord_Deserialization(t *testing.T) {
|
|
const jsonData = `{"$type":"io.atcr.sailor.star","subject":"at://did:plc:alice123/io.atcr.repo.page/myapp","createdAt":"2025-01-01T00:00:00Z"}`
|
|
|
|
var record StarRecord
|
|
if err := json.Unmarshal([]byte(jsonData), &record); err != nil {
|
|
t.Fatalf("Unmarshal error: %v", err)
|
|
}
|
|
|
|
if record.Subject != "at://did:plc:alice123/io.atcr.repo.page/myapp" {
|
|
t.Errorf("Subject = %v", record.Subject)
|
|
}
|
|
|
|
ownerDID, repo, err := record.GetSubjectDIDAndRepository()
|
|
if err != nil {
|
|
t.Fatalf("GetSubjectDIDAndRepository error: %v", err)
|
|
}
|
|
if ownerDID != "did:plc:alice123" {
|
|
t.Errorf("ownerDID = %v", ownerDID)
|
|
}
|
|
if repo != "myapp" {
|
|
t.Errorf("repository = %v", repo)
|
|
}
|
|
}
|
|
|
|
func TestBuildRepoPageURI(t *testing.T) {
|
|
uri := BuildRepoPageURI("did:plc:abc123", "quickslice")
|
|
want := "at://did:plc:abc123/io.atcr.repo.page/quickslice"
|
|
if uri != want {
|
|
t.Errorf("BuildRepoPageURI = %v, want %v", uri, want)
|
|
}
|
|
}
|
|
|
|
func TestParseRepoPageURI(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
uri string
|
|
wantOwnerDID string
|
|
wantRepo string
|
|
wantErr bool
|
|
}{
|
|
{
|
|
name: "valid URI",
|
|
uri: "at://did:plc:abc123/io.atcr.repo.page/quickslice",
|
|
wantOwnerDID: "did:plc:abc123",
|
|
wantRepo: "quickslice",
|
|
},
|
|
{
|
|
name: "missing at:// prefix",
|
|
uri: "did:plc:abc123/io.atcr.repo.page/quickslice",
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "wrong collection",
|
|
uri: "at://did:plc:abc123/io.atcr.manifest/quickslice",
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "too few parts",
|
|
uri: "at://did:plc:abc123/io.atcr.repo.page",
|
|
wantErr: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
ownerDID, repo, err := ParseRepoPageURI(tt.uri)
|
|
if (err != nil) != tt.wantErr {
|
|
t.Fatalf("ParseRepoPageURI error = %v, wantErr %v", err, tt.wantErr)
|
|
}
|
|
if tt.wantErr {
|
|
return
|
|
}
|
|
if ownerDID != tt.wantOwnerDID {
|
|
t.Errorf("ownerDID = %v, want %v", ownerDID, tt.wantOwnerDID)
|
|
}
|
|
if repo != tt.wantRepo {
|
|
t.Errorf("repository = %v, want %v", repo, tt.wantRepo)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestBuildParseRepoPageURI_Roundtrip(t *testing.T) {
|
|
ownerDID := "did:plc:dzmqinfp7efnofbqg5npjmth"
|
|
repo := "quickslice"
|
|
|
|
uri := BuildRepoPageURI(ownerDID, repo)
|
|
gotDID, gotRepo, err := ParseRepoPageURI(uri)
|
|
if err != nil {
|
|
t.Fatalf("ParseRepoPageURI error: %v", err)
|
|
}
|
|
if gotDID != ownerDID {
|
|
t.Errorf("ownerDID = %v, want %v", gotDID, ownerDID)
|
|
}
|
|
if gotRepo != repo {
|
|
t.Errorf("repository = %v, want %v", gotRepo, repo)
|
|
}
|
|
}
|
|
|
|
func TestRepositoryTagToRKey(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
repository string
|
|
tag string
|
|
want string
|
|
}{
|
|
{
|
|
name: "simple repository and tag",
|
|
repository: "myapp",
|
|
tag: "latest",
|
|
want: "myapp_latest",
|
|
},
|
|
{
|
|
name: "repository with slash",
|
|
repository: "org/myapp",
|
|
tag: "v1.0.0",
|
|
want: "org~myapp_v1.0.0",
|
|
},
|
|
{
|
|
name: "multiple slashes in repository",
|
|
repository: "github.com/user/repo",
|
|
tag: "main",
|
|
want: "github.com~user~repo_main",
|
|
},
|
|
{
|
|
name: "tag with version",
|
|
repository: "app",
|
|
tag: "v1.2.3",
|
|
want: "app_v1.2.3",
|
|
},
|
|
{
|
|
name: "repository with hyphen",
|
|
repository: "my-app",
|
|
tag: "prod",
|
|
want: "my-app_prod",
|
|
},
|
|
{
|
|
name: "empty repository",
|
|
repository: "",
|
|
tag: "latest",
|
|
want: "_latest",
|
|
},
|
|
{
|
|
name: "empty tag",
|
|
repository: "myapp",
|
|
tag: "",
|
|
want: "myapp_",
|
|
},
|
|
{
|
|
name: "both empty",
|
|
repository: "",
|
|
tag: "",
|
|
want: "_",
|
|
},
|
|
{
|
|
name: "complex repository with slash",
|
|
repository: "namespace/app",
|
|
tag: "v2.0",
|
|
want: "namespace~app_v2.0",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := RepositoryTagToRKey(tt.repository, tt.tag)
|
|
if got != tt.want {
|
|
t.Errorf("RepositoryTagToRKey(%q, %q) = %q, want %q", tt.repository, tt.tag, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRKeyToRepositoryTag(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
rkey string
|
|
wantRepository string
|
|
wantTag string
|
|
}{
|
|
{
|
|
name: "simple rkey",
|
|
rkey: "myapp_latest",
|
|
wantRepository: "myapp",
|
|
wantTag: "latest",
|
|
},
|
|
{
|
|
name: "repository with tilde (encoded slash)",
|
|
rkey: "org~myapp_v1.0.0",
|
|
wantRepository: "org/myapp",
|
|
wantTag: "v1.0.0",
|
|
},
|
|
{
|
|
name: "multiple tildes",
|
|
rkey: "github.com~user~repo_main",
|
|
wantRepository: "github.com/user/repo",
|
|
wantTag: "main",
|
|
},
|
|
{
|
|
name: "tag with underscore (splits on last underscore)",
|
|
rkey: "app_tag_with_underscore",
|
|
wantRepository: "app_tag_with",
|
|
wantTag: "underscore",
|
|
},
|
|
{
|
|
name: "repository with hyphen",
|
|
rkey: "my-app_prod",
|
|
wantRepository: "my-app",
|
|
wantTag: "prod",
|
|
},
|
|
{
|
|
name: "no underscore (treats as tag)",
|
|
rkey: "justtext",
|
|
wantRepository: "",
|
|
wantTag: "justtext",
|
|
},
|
|
{
|
|
name: "empty repository",
|
|
rkey: "_latest",
|
|
wantRepository: "",
|
|
wantTag: "latest",
|
|
},
|
|
{
|
|
name: "empty tag",
|
|
rkey: "myapp_",
|
|
wantRepository: "myapp",
|
|
wantTag: "",
|
|
},
|
|
{
|
|
name: "complex with tilde and multiple underscores",
|
|
rkey: "namespace~app_tag_with_underscore",
|
|
wantRepository: "namespace/app_tag_with",
|
|
wantTag: "underscore",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
gotRepository, gotTag := RKeyToRepositoryTag(tt.rkey)
|
|
if gotRepository != tt.wantRepository {
|
|
t.Errorf("RKeyToRepositoryTag(%q) repository = %q, want %q", tt.rkey, gotRepository, tt.wantRepository)
|
|
}
|
|
if gotTag != tt.wantTag {
|
|
t.Errorf("RKeyToRepositoryTag(%q) tag = %q, want %q", tt.rkey, gotTag, tt.wantTag)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRepositoryTagRoundTrip(t *testing.T) {
|
|
// Test that converting to rkey and back gives original values
|
|
tests := []struct {
|
|
name string
|
|
repository string
|
|
tag string
|
|
}{
|
|
{"simple", "myapp", "latest"},
|
|
{"with slash", "org/myapp", "v1.0.0"},
|
|
{"multiple slashes", "github.com/user/repo", "main"},
|
|
{"with hyphen", "my-app", "prod"},
|
|
{"empty repository", "", "latest"},
|
|
{"empty tag", "myapp", ""},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
// Convert to rkey
|
|
rkey := RepositoryTagToRKey(tt.repository, tt.tag)
|
|
|
|
// Convert back
|
|
gotRepository, gotTag := RKeyToRepositoryTag(rkey)
|
|
|
|
// Verify round-trip
|
|
if gotRepository != tt.repository {
|
|
t.Errorf("Round-trip repository = %q, want %q (via rkey %q)", gotRepository, tt.repository, rkey)
|
|
}
|
|
if gotTag != tt.tag {
|
|
t.Errorf("Round-trip tag = %q, want %q (via rkey %q)", gotTag, tt.tag, rkey)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestNewLayerRecord(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
digest string
|
|
size int64
|
|
mediaType string
|
|
userDID string
|
|
manifestURI string
|
|
}{
|
|
{
|
|
name: "standard layer",
|
|
digest: "sha256:abc123",
|
|
size: 1024,
|
|
mediaType: "application/vnd.oci.image.layer.v1.tar+gzip",
|
|
userDID: "did:plc:user123",
|
|
manifestURI: "at://did:plc:user123/io.atcr.manifest/abc123",
|
|
},
|
|
{
|
|
name: "large layer",
|
|
digest: "sha256:def456",
|
|
size: 1073741824, // 1GB
|
|
mediaType: "application/vnd.oci.image.layer.v1.tar+gzip",
|
|
userDID: "did:plc:user456",
|
|
manifestURI: "at://did:plc:user456/io.atcr.manifest/def456",
|
|
},
|
|
{
|
|
name: "empty values",
|
|
digest: "",
|
|
size: 0,
|
|
mediaType: "",
|
|
userDID: "",
|
|
manifestURI: "",
|
|
},
|
|
{
|
|
name: "config layer",
|
|
digest: "sha256:config123",
|
|
size: 512,
|
|
mediaType: "application/vnd.oci.image.config.v1+json",
|
|
userDID: "did:web:example.com",
|
|
manifestURI: "at://did:web:example.com/io.atcr.manifest/config123",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
record := NewLayerRecord(tt.digest, tt.size, tt.mediaType, tt.userDID, tt.manifestURI)
|
|
|
|
// Verify all fields
|
|
if record == nil {
|
|
t.Fatal("NewLayerRecord() returned nil")
|
|
}
|
|
|
|
if record.Type != LayerCollection {
|
|
t.Errorf("Type = %q, want %q", record.Type, LayerCollection)
|
|
}
|
|
|
|
if record.Digest != tt.digest {
|
|
t.Errorf("Digest = %q, want %q", record.Digest, tt.digest)
|
|
}
|
|
|
|
if record.Size != tt.size {
|
|
t.Errorf("Size = %d, want %d", record.Size, tt.size)
|
|
}
|
|
|
|
if record.MediaType != tt.mediaType {
|
|
t.Errorf("MediaType = %q, want %q", record.MediaType, tt.mediaType)
|
|
}
|
|
|
|
if record.Manifest != tt.manifestURI {
|
|
t.Errorf("Manifest = %q, want %q", record.Manifest, tt.manifestURI)
|
|
}
|
|
|
|
if record.UserDID != tt.userDID {
|
|
t.Errorf("UserDID = %q, want %q", record.UserDID, tt.userDID)
|
|
}
|
|
|
|
// Verify CreatedAt is set and is a valid RFC3339 timestamp
|
|
if record.CreatedAt == "" {
|
|
t.Error("CreatedAt is empty")
|
|
}
|
|
|
|
// Parse to verify it's a valid timestamp
|
|
_, err := time.Parse(time.RFC3339, record.CreatedAt)
|
|
if err != nil {
|
|
t.Errorf("CreatedAt %q is not a valid RFC3339 timestamp: %v", record.CreatedAt, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestNewLayerRecordJSON(t *testing.T) {
|
|
// Test that LayerRecord can be marshaled/unmarshaled to/from JSON
|
|
record := NewLayerRecord(
|
|
"sha256:abc123",
|
|
1024,
|
|
"application/vnd.oci.image.layer.v1.tar+gzip",
|
|
"did:plc:user123",
|
|
"at://did:plc:user123/io.atcr.manifest/abc123",
|
|
)
|
|
|
|
// Marshal to JSON
|
|
jsonData, err := json.Marshal(record)
|
|
if err != nil {
|
|
t.Fatalf("json.Marshal() error = %v", err)
|
|
}
|
|
|
|
// Unmarshal back
|
|
var decoded LayerRecord
|
|
if err := json.Unmarshal(jsonData, &decoded); err != nil {
|
|
t.Fatalf("json.Unmarshal() error = %v", err)
|
|
}
|
|
|
|
// Verify fields match
|
|
if decoded.Type != record.Type {
|
|
t.Errorf("Type = %q, want %q", decoded.Type, record.Type)
|
|
}
|
|
if decoded.Digest != record.Digest {
|
|
t.Errorf("Digest = %q, want %q", decoded.Digest, record.Digest)
|
|
}
|
|
if decoded.Size != record.Size {
|
|
t.Errorf("Size = %d, want %d", decoded.Size, record.Size)
|
|
}
|
|
if decoded.MediaType != record.MediaType {
|
|
t.Errorf("MediaType = %q, want %q", decoded.MediaType, record.MediaType)
|
|
}
|
|
if decoded.Manifest != record.Manifest {
|
|
t.Errorf("Manifest = %q, want %q", decoded.Manifest, record.Manifest)
|
|
}
|
|
if decoded.UserDID != record.UserDID {
|
|
t.Errorf("UserDID = %q, want %q", decoded.UserDID, record.UserDID)
|
|
}
|
|
if decoded.CreatedAt != record.CreatedAt {
|
|
t.Errorf("CreatedAt = %q, want %q", decoded.CreatedAt, record.CreatedAt)
|
|
}
|
|
}
|
|
|
|
func TestNewRepoPageRecord(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
repository string
|
|
description string
|
|
avatar *ATProtoBlobRef
|
|
}{
|
|
{
|
|
name: "with description only",
|
|
repository: "myapp",
|
|
description: "# My App\n\nA cool container image.",
|
|
avatar: nil,
|
|
},
|
|
{
|
|
name: "with avatar only",
|
|
repository: "another-app",
|
|
description: "",
|
|
avatar: &ATProtoBlobRef{
|
|
Type: "blob",
|
|
Ref: Link{Link: "bafyreiabc123"},
|
|
MimeType: "image/png",
|
|
Size: 1024,
|
|
},
|
|
},
|
|
{
|
|
name: "with both description and avatar",
|
|
repository: "full-app",
|
|
description: "This is a full description.",
|
|
avatar: &ATProtoBlobRef{
|
|
Type: "blob",
|
|
Ref: Link{Link: "bafyreiabc456"},
|
|
MimeType: "image/jpeg",
|
|
Size: 2048,
|
|
},
|
|
},
|
|
{
|
|
name: "empty values",
|
|
repository: "",
|
|
description: "",
|
|
avatar: nil,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
before := time.Now()
|
|
record := NewRepoPageRecord(tt.repository, tt.description, tt.avatar)
|
|
after := time.Now()
|
|
|
|
if record.Type != RepoPageCollection {
|
|
t.Errorf("Type = %v, want %v", record.Type, RepoPageCollection)
|
|
}
|
|
|
|
if record.Repository != tt.repository {
|
|
t.Errorf("Repository = %v, want %v", record.Repository, tt.repository)
|
|
}
|
|
|
|
if record.Description != tt.description {
|
|
t.Errorf("Description = %v, want %v", record.Description, tt.description)
|
|
}
|
|
|
|
if tt.avatar == nil && record.Avatar != nil {
|
|
t.Error("Avatar should be nil")
|
|
}
|
|
|
|
if tt.avatar != nil {
|
|
if record.Avatar == nil {
|
|
t.Fatal("Avatar should not be nil")
|
|
}
|
|
if record.Avatar.Ref.Link != tt.avatar.Ref.Link {
|
|
t.Errorf("Avatar.Ref.Link = %v, want %v", record.Avatar.Ref.Link, tt.avatar.Ref.Link)
|
|
}
|
|
}
|
|
|
|
if record.CreatedAt.Before(before) || record.CreatedAt.After(after) {
|
|
t.Errorf("CreatedAt = %v, want between %v and %v", record.CreatedAt, before, after)
|
|
}
|
|
|
|
if record.UpdatedAt.Before(before) || record.UpdatedAt.After(after) {
|
|
t.Errorf("UpdatedAt = %v, want between %v and %v", record.UpdatedAt, before, after)
|
|
}
|
|
|
|
// CreatedAt and UpdatedAt should be equal for new records
|
|
if !record.CreatedAt.Equal(record.UpdatedAt) {
|
|
t.Errorf("CreatedAt (%v) != UpdatedAt (%v)", record.CreatedAt, record.UpdatedAt)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRepoPageRecord_JSONSerialization(t *testing.T) {
|
|
record := NewRepoPageRecord(
|
|
"myapp",
|
|
"# My App\n\nA description with **markdown**.",
|
|
&ATProtoBlobRef{
|
|
Type: "blob",
|
|
Ref: Link{Link: "bafyreiabc123"},
|
|
MimeType: "image/png",
|
|
Size: 1024,
|
|
},
|
|
)
|
|
|
|
// Serialize to JSON
|
|
jsonData, err := json.Marshal(record)
|
|
if err != nil {
|
|
t.Fatalf("json.Marshal() error = %v", err)
|
|
}
|
|
|
|
// Deserialize from JSON
|
|
var decoded RepoPageRecord
|
|
if err := json.Unmarshal(jsonData, &decoded); err != nil {
|
|
t.Fatalf("json.Unmarshal() error = %v", err)
|
|
}
|
|
|
|
// Verify fields
|
|
if decoded.Type != record.Type {
|
|
t.Errorf("Type = %v, want %v", decoded.Type, record.Type)
|
|
}
|
|
if decoded.Repository != record.Repository {
|
|
t.Errorf("Repository = %v, want %v", decoded.Repository, record.Repository)
|
|
}
|
|
if decoded.Description != record.Description {
|
|
t.Errorf("Description = %v, want %v", decoded.Description, record.Description)
|
|
}
|
|
if decoded.Avatar == nil {
|
|
t.Fatal("Avatar should not be nil")
|
|
}
|
|
if decoded.Avatar.Ref.Link != record.Avatar.Ref.Link {
|
|
t.Errorf("Avatar.Ref.Link = %v, want %v", decoded.Avatar.Ref.Link, record.Avatar.Ref.Link)
|
|
}
|
|
}
|
|
|
|
// TestImageConfigRecord_CBORRoundTrip locks in that we can encode and decode
|
|
// large OCI image configs without hitting cbor-gen's default 8KB string cap.
|
|
// Real-world images with deep build histories (Bazel, multi-stage Dockerfiles)
|
|
// routinely produce config blobs that blow past the default; if either side
|
|
// regresses, the backfill silently drops records with "configJson was too
|
|
// long" instead of populating the layer-history UI.
|
|
func TestImageConfigRecord_CBORRoundTrip(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
payloadSize int
|
|
}{
|
|
// Small payloads exercise the happy path — should always have worked.
|
|
{"small", 1024},
|
|
// 16KB is well past the old 8192 cborgen default. Pre-fix this would
|
|
// have failed at marshal time.
|
|
{"medium-16kb", 16 * 1024},
|
|
// 200KB matches the upper end of pathological real configs (deep
|
|
// Bazel histories with verbose created_by lines).
|
|
{"large-200kb", 200 * 1024},
|
|
// 900KB is just under our 1MB cap — the read side previously
|
|
// hard-coded 8192 even with the per-field write tag, so this also
|
|
// guards against the unmarshal regression.
|
|
{"near-cap-900kb", 900 * 1024},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
// Use a repeating non-trivial pattern so any byte-level corruption
|
|
// in encode/decode shows up as a mismatch rather than blending
|
|
// into a sea of identical bytes.
|
|
const chunk = "history entry: bazel build //pkg:foo # "
|
|
payload := strings.Repeat(chunk, tc.payloadSize/len(chunk)+1)[:tc.payloadSize]
|
|
|
|
record := &ImageConfigRecord{
|
|
Type: ImageConfigCollection,
|
|
Manifest: "at://did:plc:test/io.atcr.manifest/abc",
|
|
ConfigJSON: payload,
|
|
CreatedAt: time.Now().UTC().Format(time.RFC3339),
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
if err := record.MarshalCBOR(&buf); err != nil {
|
|
t.Fatalf("MarshalCBOR(%d bytes): %v", tc.payloadSize, err)
|
|
}
|
|
|
|
var decoded ImageConfigRecord
|
|
if err := decoded.UnmarshalCBOR(&buf); err != nil {
|
|
t.Fatalf("UnmarshalCBOR(%d bytes): %v", tc.payloadSize, err)
|
|
}
|
|
|
|
if decoded.Type != record.Type {
|
|
t.Errorf("Type = %q, want %q", decoded.Type, record.Type)
|
|
}
|
|
if decoded.Manifest != record.Manifest {
|
|
t.Errorf("Manifest = %q, want %q", decoded.Manifest, record.Manifest)
|
|
}
|
|
if decoded.CreatedAt != record.CreatedAt {
|
|
t.Errorf("CreatedAt = %q, want %q", decoded.CreatedAt, record.CreatedAt)
|
|
}
|
|
if len(decoded.ConfigJSON) != len(record.ConfigJSON) {
|
|
t.Fatalf("ConfigJSON length = %d, want %d", len(decoded.ConfigJSON), len(record.ConfigJSON))
|
|
}
|
|
if decoded.ConfigJSON != record.ConfigJSON {
|
|
t.Errorf("ConfigJSON content mismatch at length %d", len(record.ConfigJSON))
|
|
}
|
|
})
|
|
}
|
|
}
|