- internal/config: TOML config with env overrides (MCR_ prefix), required field validation, same-filesystem check, defaults - internal/db: SQLite via modernc.org/sqlite, WAL mode, 2 migrations (core registry tables + policy/audit), foreign key cascades - internal/db: audit log write/list with filtering and pagination - deploy/examples/mcr.toml: annotated example configuration - .golangci.yaml: disable fieldalignment (readability over micro-opt) - checkpoint skill copied from mcias Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
198 lines
4.7 KiB
Go
198 lines
4.7 KiB
Go
package db
|
|
|
|
import (
|
|
"path/filepath"
|
|
"testing"
|
|
)
|
|
|
|
func openTestDB(t *testing.T) *DB {
|
|
t.Helper()
|
|
path := filepath.Join(t.TempDir(), "test.db")
|
|
d, err := Open(path)
|
|
if err != nil {
|
|
t.Fatalf("Open: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = d.Close() })
|
|
return d
|
|
}
|
|
|
|
func TestOpenAndMigrate(t *testing.T) {
|
|
d := openTestDB(t)
|
|
|
|
if err := d.Migrate(); err != nil {
|
|
t.Fatalf("Migrate: %v", err)
|
|
}
|
|
|
|
// Verify schema version.
|
|
ver, err := d.SchemaVersion()
|
|
if err != nil {
|
|
t.Fatalf("SchemaVersion: %v", err)
|
|
}
|
|
if ver != 2 {
|
|
t.Fatalf("schema version: got %d, want 2", ver)
|
|
}
|
|
}
|
|
|
|
func TestMigrateIdempotent(t *testing.T) {
|
|
d := openTestDB(t)
|
|
|
|
if err := d.Migrate(); err != nil {
|
|
t.Fatalf("Migrate (first): %v", err)
|
|
}
|
|
if err := d.Migrate(); err != nil {
|
|
t.Fatalf("Migrate (second): %v", err)
|
|
}
|
|
|
|
ver, err := d.SchemaVersion()
|
|
if err != nil {
|
|
t.Fatalf("SchemaVersion: %v", err)
|
|
}
|
|
if ver != 2 {
|
|
t.Fatalf("schema version: got %d, want 2", ver)
|
|
}
|
|
}
|
|
|
|
func TestTablesExist(t *testing.T) {
|
|
d := openTestDB(t)
|
|
if err := d.Migrate(); err != nil {
|
|
t.Fatalf("Migrate: %v", err)
|
|
}
|
|
|
|
tables := []string{
|
|
"schema_migrations",
|
|
"repositories",
|
|
"manifests",
|
|
"tags",
|
|
"blobs",
|
|
"manifest_blobs",
|
|
"uploads",
|
|
"policy_rules",
|
|
"audit_log",
|
|
}
|
|
|
|
for _, table := range tables {
|
|
var name string
|
|
err := d.QueryRow(
|
|
`SELECT name FROM sqlite_master WHERE type='table' AND name=?`, table,
|
|
).Scan(&name)
|
|
if err != nil {
|
|
t.Fatalf("table %q not found: %v", table, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestForeignKeyEnforcement(t *testing.T) {
|
|
d := openTestDB(t)
|
|
if err := d.Migrate(); err != nil {
|
|
t.Fatalf("Migrate: %v", err)
|
|
}
|
|
|
|
// Inserting a manifest with a nonexistent repository_id should fail.
|
|
_, err := d.Exec(`INSERT INTO manifests (repository_id, digest, media_type, content, size)
|
|
VALUES (9999, 'sha256:abc', 'application/json', '{}', 2)`)
|
|
if err == nil {
|
|
t.Fatal("expected foreign key violation, got nil")
|
|
}
|
|
}
|
|
|
|
func TestTagCascadeOnManifestDelete(t *testing.T) {
|
|
d := openTestDB(t)
|
|
if err := d.Migrate(); err != nil {
|
|
t.Fatalf("Migrate: %v", err)
|
|
}
|
|
|
|
// Create a repository, manifest, and tag.
|
|
_, err := d.Exec(`INSERT INTO repositories (name) VALUES ('testrepo')`)
|
|
if err != nil {
|
|
t.Fatalf("insert repo: %v", err)
|
|
}
|
|
|
|
_, err = d.Exec(`INSERT INTO manifests (repository_id, digest, media_type, content, size)
|
|
VALUES (1, 'sha256:abc123', 'application/vnd.oci.image.manifest.v1+json', '{}', 2)`)
|
|
if err != nil {
|
|
t.Fatalf("insert manifest: %v", err)
|
|
}
|
|
|
|
_, err = d.Exec(`INSERT INTO tags (repository_id, name, manifest_id) VALUES (1, 'latest', 1)`)
|
|
if err != nil {
|
|
t.Fatalf("insert tag: %v", err)
|
|
}
|
|
|
|
// Delete the manifest — tag should cascade.
|
|
_, err = d.Exec(`DELETE FROM manifests WHERE id = 1`)
|
|
if err != nil {
|
|
t.Fatalf("delete manifest: %v", err)
|
|
}
|
|
|
|
var count int
|
|
if err := d.QueryRow(`SELECT COUNT(*) FROM tags`).Scan(&count); err != nil {
|
|
t.Fatalf("count tags: %v", err)
|
|
}
|
|
if count != 0 {
|
|
t.Fatalf("tags after manifest delete: got %d, want 0", count)
|
|
}
|
|
}
|
|
|
|
func TestManifestBlobsCascadeOnManifestDelete(t *testing.T) {
|
|
d := openTestDB(t)
|
|
if err := d.Migrate(); err != nil {
|
|
t.Fatalf("Migrate: %v", err)
|
|
}
|
|
|
|
_, err := d.Exec(`INSERT INTO repositories (name) VALUES ('testrepo')`)
|
|
if err != nil {
|
|
t.Fatalf("insert repo: %v", err)
|
|
}
|
|
|
|
_, err = d.Exec(`INSERT INTO manifests (repository_id, digest, media_type, content, size)
|
|
VALUES (1, 'sha256:abc123', 'application/vnd.oci.image.manifest.v1+json', '{}', 2)`)
|
|
if err != nil {
|
|
t.Fatalf("insert manifest: %v", err)
|
|
}
|
|
|
|
_, err = d.Exec(`INSERT INTO blobs (digest, size) VALUES ('sha256:layer1', 1024)`)
|
|
if err != nil {
|
|
t.Fatalf("insert blob: %v", err)
|
|
}
|
|
|
|
_, err = d.Exec(`INSERT INTO manifest_blobs (manifest_id, blob_id) VALUES (1, 1)`)
|
|
if err != nil {
|
|
t.Fatalf("insert manifest_blobs: %v", err)
|
|
}
|
|
|
|
// Delete manifest — manifest_blobs should cascade, blob should remain.
|
|
_, err = d.Exec(`DELETE FROM manifests WHERE id = 1`)
|
|
if err != nil {
|
|
t.Fatalf("delete manifest: %v", err)
|
|
}
|
|
|
|
var mbCount int
|
|
if err := d.QueryRow(`SELECT COUNT(*) FROM manifest_blobs`).Scan(&mbCount); err != nil {
|
|
t.Fatalf("count manifest_blobs: %v", err)
|
|
}
|
|
if mbCount != 0 {
|
|
t.Fatalf("manifest_blobs after delete: got %d, want 0", mbCount)
|
|
}
|
|
|
|
// Blob row should still exist (GC handles file cleanup).
|
|
var blobCount int
|
|
if err := d.QueryRow(`SELECT COUNT(*) FROM blobs`).Scan(&blobCount); err != nil {
|
|
t.Fatalf("count blobs: %v", err)
|
|
}
|
|
if blobCount != 1 {
|
|
t.Fatalf("blobs after manifest delete: got %d, want 1", blobCount)
|
|
}
|
|
}
|
|
|
|
func TestWALMode(t *testing.T) {
|
|
d := openTestDB(t)
|
|
|
|
var mode string
|
|
if err := d.QueryRow(`PRAGMA journal_mode`).Scan(&mode); err != nil {
|
|
t.Fatalf("PRAGMA journal_mode: %v", err)
|
|
}
|
|
if mode != "wal" {
|
|
t.Fatalf("journal_mode: got %q, want %q", mode, "wal")
|
|
}
|
|
}
|