Files
at-container-registry/pkg/atproto/lexicon_test.go
T

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))
}
})
}
}