A container registry that uses the AT Protocol for manifest storage and S3 for blob storage.
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

more appview cleanup and test coverage

+2358 -275
+3 -5
cmd/appview/authorizer_test.go pkg/appview/db/readonly_test.go
··· 1 - package main 1 + package db 2 2 3 3 import ( 4 4 "database/sql" 5 5 "os" 6 6 "path/filepath" 7 7 "testing" 8 - 9 - "atcr.io/pkg/appview/db" 10 8 ) 11 9 12 10 func TestAuthorizerBlocksSensitiveTables(t *testing.T) { ··· 19 17 defer os.Unsetenv("ATCR_UI_DATABASE_PATH") 20 18 21 19 // Initialize database (creates schema) 22 - database, err := db.InitDB(dbPath) 20 + database, err := InitDB(dbPath) 23 21 if err != nil { 24 22 t.Fatalf("Failed to initialize database: %v", err) 25 23 } ··· 43 41 } 44 42 45 43 // Open read-only connection with authorizer (using our custom driver) 46 - readOnlyDB, err := sql.Open("sqlite3_readonly_public", "file:"+dbPath+"?mode=ro") 44 + readOnlyDB, err := sql.Open(ReadOnlyDriverName, "file:"+dbPath+"?mode=ro") 47 45 if err != nil { 48 46 t.Fatalf("Failed to open read-only database: %v", err) 49 47 }
+67 -14
cmd/appview/config.go pkg/appview/config.go
··· 1 - package main 1 + package appview 2 2 3 3 import ( 4 4 "crypto/rand" ··· 12 12 "github.com/distribution/distribution/v3/configuration" 13 13 ) 14 14 15 - // loadConfigFromEnv builds a complete configuration from environment variables 15 + // LoadConfigFromEnv builds a complete configuration from environment variables 16 16 // This follows the same pattern as the hold service (no config files, only env vars) 17 - func loadConfigFromEnv() (*configuration.Configuration, error) { 17 + func LoadConfigFromEnv() (*configuration.Configuration, error) { 18 18 config := &configuration.Configuration{} 19 19 20 20 // Version ··· 56 56 57 57 // buildLogConfig creates logging configuration from environment variables 58 58 func buildLogConfig() configuration.Log { 59 - level := getEnvOrDefault("ATCR_LOG_LEVEL", "info") 60 - formatter := getEnvOrDefault("ATCR_LOG_FORMATTER", "text") 59 + level := GetEnvOrDefault("ATCR_LOG_LEVEL", "info") 60 + formatter := GetEnvOrDefault("ATCR_LOG_FORMATTER", "text") 61 61 62 62 return configuration.Log{ 63 63 Level: configuration.Loglevel(level), ··· 70 70 71 71 // buildHTTPConfig creates HTTP server configuration from environment variables 72 72 func buildHTTPConfig() (configuration.HTTP, error) { 73 - addr := getEnvOrDefault("ATCR_HTTP_ADDR", ":5000") 74 - debugAddr := getEnvOrDefault("ATCR_DEBUG_ADDR", ":5001") 73 + addr := GetEnvOrDefault("ATCR_HTTP_ADDR", ":5000") 74 + debugAddr := GetEnvOrDefault("ATCR_DEBUG_ADDR", ":5001") 75 75 76 76 // HTTP secret - only needed for multipart uploads in distribution's storage driver 77 77 // Since AppView is stateless and routes all storage through middleware, this isn't ··· 143 143 // buildAuthConfig creates authentication configuration from environment variables 144 144 func buildAuthConfig(baseURL string) (configuration.Auth, error) { 145 145 // Token configuration 146 - privateKeyPath := getEnvOrDefault("ATCR_AUTH_KEY_PATH", "/var/lib/atcr/auth/private-key.pem") 147 - certPath := getEnvOrDefault("ATCR_AUTH_CERT_PATH", "/var/lib/atcr/auth/private-key.crt") 146 + privateKeyPath := GetEnvOrDefault("ATCR_AUTH_KEY_PATH", "/var/lib/atcr/auth/private-key.pem") 147 + certPath := GetEnvOrDefault("ATCR_AUTH_CERT_PATH", "/var/lib/atcr/auth/private-key.crt") 148 148 149 149 // Token expiration in seconds (default: 5 minutes) 150 - expirationStr := getEnvOrDefault("ATCR_TOKEN_EXPIRATION", "300") 150 + expirationStr := GetEnvOrDefault("ATCR_TOKEN_EXPIRATION", "300") 151 151 expiration, err := strconv.Atoi(expirationStr) 152 152 if err != nil { 153 153 return configuration.Auth{}, fmt.Errorf("invalid ATCR_TOKEN_EXPIRATION: %w", err) ··· 182 182 } 183 183 } 184 184 185 - // getBaseURL determines the base URL for the service 185 + // GetBaseURL determines the base URL for the service 186 186 // Priority: ATCR_BASE_URL env var, then derived from HTTP addr 187 - func getBaseURL(httpAddr string) string { 187 + func GetBaseURL(httpAddr string) string { 188 188 baseURL := os.Getenv("ATCR_BASE_URL") 189 189 if baseURL != "" { 190 190 return baseURL ··· 198 198 199 199 // Full address provided 200 200 return fmt.Sprintf("http://%s", httpAddr) 201 + } 202 + 203 + // getBaseURL is the internal version used by buildAuthConfig 204 + func getBaseURL(httpAddr string) string { 205 + return GetBaseURL(httpAddr) 201 206 } 202 207 203 208 // getServiceName extracts service name from base URL or uses env var ··· 224 229 return "atcr.io" 225 230 } 226 231 227 - // getEnvOrDefault gets an environment variable or returns a default value 228 - func getEnvOrDefault(key, defaultValue string) string { 232 + // GetEnvOrDefault gets an environment variable or returns a default value 233 + func GetEnvOrDefault(key, defaultValue string) string { 229 234 if val := os.Getenv(key); val != "" { 230 235 return val 231 236 } 232 237 return defaultValue 233 238 } 239 + 240 + // GetStringParam extracts a string parameter from configuration.Parameters 241 + func GetStringParam(params configuration.Parameters, key, defaultValue string) string { 242 + if v, ok := params[key]; ok { 243 + if s, ok := v.(string); ok { 244 + return s 245 + } 246 + } 247 + return defaultValue 248 + } 249 + 250 + // GetIntParam extracts an int parameter from configuration.Parameters 251 + func GetIntParam(params configuration.Parameters, key string, defaultValue int) int { 252 + if v, ok := params[key]; ok { 253 + if i, ok := v.(int); ok { 254 + return i 255 + } 256 + } 257 + return defaultValue 258 + } 259 + 260 + // ExtractDefaultHoldDID extracts the default hold DID from middleware config 261 + // Returns a DID (e.g., "did:web:hold01.atcr.io") 262 + // To find a hold's DID, visit: https://hold-url/.well-known/did.json 263 + func ExtractDefaultHoldDID(config *configuration.Configuration) string { 264 + // Navigate through: middleware.registry[].options.default_hold_did 265 + registryMiddleware, ok := config.Middleware["registry"] 266 + if !ok { 267 + return "" 268 + } 269 + 270 + // Find atproto-resolver middleware 271 + for _, mw := range registryMiddleware { 272 + // Check if this is the atproto-resolver 273 + if mw.Name != "atproto-resolver" { 274 + continue 275 + } 276 + 277 + // Extract options - options is configuration.Parameters which is map[string]any 278 + if mw.Options != nil { 279 + if holdDID, ok := mw.Options["default_hold_did"].(string); ok { 280 + return holdDID 281 + } 282 + } 283 + } 284 + 285 + return "" 286 + }
+16 -164
cmd/appview/serve.go
··· 9 9 "net/http" 10 10 "os" 11 11 "os/signal" 12 - "path/filepath" 13 12 "syscall" 14 13 "time" 15 14 16 15 "github.com/distribution/distribution/v3/configuration" 17 16 "github.com/distribution/distribution/v3/registry" 18 17 "github.com/distribution/distribution/v3/registry/handlers" 19 - sqlite3 "github.com/mattn/go-sqlite3" 20 18 "github.com/spf13/cobra" 21 19 22 20 "atcr.io/pkg/appview/middleware" ··· 32 30 "github.com/gorilla/mux" 33 31 ) 34 32 35 - // Define sensitive tables that should never be accessible from public queries 36 - var sensitiveTables = map[string]bool{ 37 - "oauth_sessions": true, // OAuth tokens 38 - "ui_sessions": true, // Session IDs 39 - "oauth_auth_requests": true, // OAuth state 40 - "devices": true, // Device secret hashes 41 - "pending_device_auth": true, // Pending device secrets 42 - } 43 - 44 - // readOnlyAuthorizerCallback blocks access to sensitive tables 45 - func readOnlyAuthorizerCallback(action int, arg1, arg2, dbName string) int { 46 - // arg1 contains the table name for most operations 47 - tableName := arg1 48 - 49 - // Block any access to sensitive tables 50 - if action == sqlite3.SQLITE_READ || action == sqlite3.SQLITE_UPDATE || 51 - action == sqlite3.SQLITE_INSERT || action == sqlite3.SQLITE_DELETE || 52 - action == sqlite3.SQLITE_SELECT { 53 - if sensitiveTables[tableName] { 54 - fmt.Printf("SECURITY: Blocked access to sensitive table '%s' (action=%d)\n", tableName, action) 55 - return sqlite3.SQLITE_DENY 56 - } 57 - } 58 - 59 - // Allow everything else 60 - return sqlite3.SQLITE_OK 61 - } 62 - 63 33 var serveCmd = &cobra.Command{ 64 34 Use: "serve", 65 35 Short: "Start the ATCR registry server", ··· 72 42 } 73 43 74 44 func init() { 75 - // Register a custom SQLite driver with authorizer for read-only public queries 76 - sql.Register("sqlite3_readonly_public", 77 - &sqlite3.SQLiteDriver{ 78 - ConnectHook: func(conn *sqlite3.SQLiteConn) error { 79 - conn.RegisterAuthorizer(readOnlyAuthorizerCallback) 80 - return nil 81 - }, 82 - }) 83 - 84 45 // Replace the default serve command with our custom one 85 46 for i, cmd := range registry.RootCmd.Commands() { 86 47 if cmd.Name() == "serve" { ··· 93 54 func serveRegistry(cmd *cobra.Command, args []string) error { 94 55 // Load configuration from environment variables 95 56 fmt.Println("Loading configuration from environment variables...") 96 - config, err := loadConfigFromEnv() 57 + config, err := appview.LoadConfigFromEnv() 97 58 if err != nil { 98 59 return fmt.Errorf("failed to load config from environment: %w", err) 99 60 } ··· 101 62 102 63 // Initialize UI database first (required for all stores) 103 64 fmt.Println("Initializing UI database...") 104 - uiDatabase, uiReadOnlyDB, uiSessionStore := initializeDatabase() 65 + uiEnabled := os.Getenv("ATCR_UI_ENABLED") != "false" 66 + dbPath := os.Getenv("ATCR_UI_DATABASE_PATH") 67 + if dbPath == "" { 68 + dbPath = "/var/lib/atcr/ui.db" 69 + } 70 + uiDatabase, uiReadOnlyDB, uiSessionStore := db.InitializeDatabase(uiEnabled, dbPath) 105 71 if uiDatabase == nil { 106 72 return fmt.Errorf("failed to initialize UI database - required for session storage") 107 73 } ··· 158 124 // Expected format: "did:web:hold01.atcr.io" 159 125 // To find a hold's DID, visit: https://hold01.atcr.io/.well-known/did.json 160 126 // The extraction function normalizes URLs to DIDs for consistency 161 - defaultHoldDID := extractDefaultHoldDID(config) 127 + defaultHoldDID := appview.ExtractDefaultHoldDID(config) 162 128 163 129 // Initialize UI routes with OAuth app, refresher, and device store 164 130 uiTemplates, uiRouter := initializeUIRoutes(uiDatabase, uiReadOnlyDB, uiSessionStore, oauthApp, refresher, baseURL, deviceStore, defaultHoldDID) ··· 304 270 return nil 305 271 } 306 272 307 - privateKeyPath := getStringParam(tokenParams, "privatekey", "/var/lib/atcr/auth/private-key.pem") 308 - issuerName := getStringParam(tokenParams, "issuer", "atcr.io") 309 - service := getStringParam(tokenParams, "service", "atcr.io") 310 - expirationSecs := getIntParam(tokenParams, "expiration", 300) 273 + privateKeyPath := appview.GetStringParam(tokenParams, "privatekey", "/var/lib/atcr/auth/private-key.pem") 274 + issuerName := appview.GetStringParam(tokenParams, "issuer", "atcr.io") 275 + service := appview.GetStringParam(tokenParams, "service", "atcr.io") 276 + expirationSecs := appview.GetIntParam(tokenParams, "expiration", 300) 311 277 312 278 // Create issuer (this will generate the key if it doesn't exist) 313 279 _, err := token.NewIssuer( ··· 331 297 return nil, fmt.Errorf("token auth not configured") 332 298 } 333 299 334 - privateKeyPath := getStringParam(tokenParams, "privatekey", "/var/lib/atcr/auth/private-key.pem") 335 - issuerName := getStringParam(tokenParams, "issuer", "atcr.io") 336 - service := getStringParam(tokenParams, "service", "atcr.io") 337 - expirationSecs := getIntParam(tokenParams, "expiration", 300) 300 + privateKeyPath := appview.GetStringParam(tokenParams, "privatekey", "/var/lib/atcr/auth/private-key.pem") 301 + issuerName := appview.GetStringParam(tokenParams, "issuer", "atcr.io") 302 + service := appview.GetStringParam(tokenParams, "service", "atcr.io") 303 + expirationSecs := appview.GetIntParam(tokenParams, "expiration", 300) 338 304 339 305 return token.NewIssuer( 340 306 privateKeyPath, ··· 342 308 service, 343 309 time.Duration(expirationSecs)*time.Second, 344 310 ) 345 - } 346 - 347 - // Helper functions to extract values from config parameters 348 - func getStringParam(params configuration.Parameters, key, defaultValue string) string { 349 - if v, ok := params[key]; ok { 350 - if s, ok := v.(string); ok { 351 - return s 352 - } 353 - } 354 - return defaultValue 355 - } 356 - 357 - func getIntParam(params configuration.Parameters, key string, defaultValue int) int { 358 - if v, ok := params[key]; ok { 359 - if i, ok := v.(int); ok { 360 - return i 361 - } 362 - } 363 - return defaultValue 364 - } 365 - 366 - // extractDefaultHoldDID extracts the default hold DID from middleware config 367 - // Returns a DID (e.g., "did:web:hold01.atcr.io") 368 - // To find a hold's DID, visit: https://hold-url/.well-known/did.json 369 - func extractDefaultHoldDID(config *configuration.Configuration) string { 370 - // Navigate through: middleware.registry[].options.default_hold_did 371 - registryMiddleware, ok := config.Middleware["registry"] 372 - if !ok { 373 - return "" 374 - } 375 - 376 - // Find atproto-resolver middleware 377 - for _, mw := range registryMiddleware { 378 - // Check if this is the atproto-resolver 379 - if mw.Name != "atproto-resolver" { 380 - continue 381 - } 382 - 383 - // Extract options - options is configuration.Parameters which is map[string]any 384 - if mw.Options != nil { 385 - if holdDID, ok := mw.Options["default_hold_did"].(string); ok { 386 - return holdDID 387 - } 388 - } 389 - } 390 - 391 - return "" 392 - } 393 - 394 - // initializeDatabase initializes the SQLite database and session store 395 - // Returns: (read-write DB, read-only DB, session store) 396 - func initializeDatabase() (*sql.DB, *sql.DB, *db.SessionStore) { 397 - // Check if UI is enabled (optional configuration) 398 - uiEnabled := os.Getenv("ATCR_UI_ENABLED") 399 - if uiEnabled == "false" { 400 - return nil, nil, nil 401 - } 402 - 403 - // Get database path 404 - dbPath := os.Getenv("ATCR_UI_DATABASE_PATH") 405 - if dbPath == "" { 406 - dbPath = "/var/lib/atcr/ui.db" 407 - } 408 - 409 - // Ensure directory exists 410 - dbDir := filepath.Dir(dbPath) 411 - if err := os.MkdirAll(dbDir, 0700); err != nil { 412 - fmt.Printf("Warning: Failed to create UI database directory: %v\n", err) 413 - return nil, nil, nil 414 - } 415 - 416 - // Initialize read-write database (for writes and auth operations) 417 - database, err := db.InitDB(dbPath) 418 - if err != nil { 419 - fmt.Printf("Warning: Failed to initialize UI database: %v\n", err) 420 - return nil, nil, nil 421 - } 422 - 423 - // Open read-only connection for public queries (search, user pages, etc.) 424 - // Uses custom driver with SQLite authorizer that blocks sensitive tables 425 - // This prevents accidental writes and blocks access to sensitive tables even if SQL injection occurs 426 - readOnlyDB, err := sql.Open("sqlite3_readonly_public", "file:"+dbPath+"?mode=ro") 427 - if err != nil { 428 - fmt.Printf("Warning: Failed to open read-only database connection: %v\n", err) 429 - return nil, nil, nil 430 - } 431 - 432 - fmt.Printf("UI database (readonly) initialized at %s\n", dbPath) 433 - 434 - // Create SQLite-backed session store 435 - sessionStore := db.NewSessionStore(database) 436 - 437 - // Start cleanup goroutines for all SQLite stores 438 - go func() { 439 - ticker := time.NewTicker(5 * time.Minute) 440 - defer ticker.Stop() 441 - for range ticker.C { 442 - ctx := context.Background() 443 - 444 - // Cleanup UI sessions 445 - sessionStore.Cleanup() 446 - 447 - // Cleanup OAuth sessions (older than 30 days) 448 - oauthStore := db.NewOAuthStore(database) 449 - oauthStore.CleanupOldSessions(ctx, 30*24*time.Hour) 450 - oauthStore.CleanupExpiredAuthRequests(ctx) 451 - 452 - // Cleanup device pending auths 453 - deviceStore := db.NewDeviceStore(database) 454 - deviceStore.CleanupExpired() 455 - } 456 - }() 457 - 458 - return database, readOnlyDB, sessionStore 459 311 } 460 312 461 313 // initializeUIRoutes initializes the web UI routes
pkg/appview/appview.go pkg/appview/ui.go
+844
pkg/appview/config_test.go
··· 1 + package appview 2 + 3 + import ( 4 + "os" 5 + "testing" 6 + 7 + "github.com/distribution/distribution/v3/configuration" 8 + ) 9 + 10 + func TestGetEnvOrDefault(t *testing.T) { 11 + tests := []struct { 12 + name string 13 + key string 14 + defaultValue string 15 + envValue string 16 + setEnv bool 17 + want string 18 + }{ 19 + { 20 + name: "env var not set", 21 + key: "TEST_VAR_NOT_SET", 22 + defaultValue: "default", 23 + setEnv: false, 24 + want: "default", 25 + }, 26 + { 27 + name: "env var set to value", 28 + key: "TEST_VAR_SET", 29 + defaultValue: "default", 30 + envValue: "custom", 31 + setEnv: true, 32 + want: "custom", 33 + }, 34 + { 35 + name: "env var set to empty string", 36 + key: "TEST_VAR_EMPTY", 37 + defaultValue: "default", 38 + envValue: "", 39 + setEnv: true, 40 + want: "default", 41 + }, 42 + } 43 + 44 + for _, tt := range tests { 45 + t.Run(tt.name, func(t *testing.T) { 46 + if tt.setEnv { 47 + t.Setenv(tt.key, tt.envValue) 48 + } 49 + 50 + got := GetEnvOrDefault(tt.key, tt.defaultValue) 51 + if got != tt.want { 52 + t.Errorf("GetEnvOrDefault() = %v, want %v", got, tt.want) 53 + } 54 + }) 55 + } 56 + } 57 + 58 + func TestGetBaseURL(t *testing.T) { 59 + tests := []struct { 60 + name string 61 + httpAddr string 62 + envBaseURL string 63 + setEnv bool 64 + want string 65 + }{ 66 + { 67 + name: "env var set", 68 + httpAddr: ":5000", 69 + envBaseURL: "https://registry.example.com", 70 + setEnv: true, 71 + want: "https://registry.example.com", 72 + }, 73 + { 74 + name: "port only - auto detect localhost", 75 + httpAddr: ":5000", 76 + setEnv: false, 77 + want: "http://127.0.0.1:5000", 78 + }, 79 + { 80 + name: "full address", 81 + httpAddr: "0.0.0.0:5000", 82 + setEnv: false, 83 + want: "http://0.0.0.0:5000", 84 + }, 85 + { 86 + name: "custom port", 87 + httpAddr: ":8080", 88 + setEnv: false, 89 + want: "http://127.0.0.1:8080", 90 + }, 91 + } 92 + 93 + for _, tt := range tests { 94 + t.Run(tt.name, func(t *testing.T) { 95 + if tt.setEnv { 96 + t.Setenv("ATCR_BASE_URL", tt.envBaseURL) 97 + } else { 98 + os.Unsetenv("ATCR_BASE_URL") 99 + } 100 + 101 + got := GetBaseURL(tt.httpAddr) 102 + if got != tt.want { 103 + t.Errorf("GetBaseURL() = %v, want %v", got, tt.want) 104 + } 105 + }) 106 + } 107 + } 108 + 109 + func Test_getServiceName(t *testing.T) { 110 + tests := []struct { 111 + name string 112 + baseURL string 113 + envService string 114 + setEnv bool 115 + want string 116 + }{ 117 + { 118 + name: "env var set", 119 + baseURL: "http://127.0.0.1:5000", 120 + envService: "custom.registry.io", 121 + setEnv: true, 122 + want: "custom.registry.io", 123 + }, 124 + { 125 + name: "localhost - use default", 126 + baseURL: "http://localhost:5000", 127 + setEnv: false, 128 + want: "atcr.io", 129 + }, 130 + { 131 + name: "127.0.0.1 - use default", 132 + baseURL: "http://127.0.0.1:5000", 133 + setEnv: false, 134 + want: "atcr.io", 135 + }, 136 + { 137 + name: "custom domain", 138 + baseURL: "https://registry.example.com", 139 + setEnv: false, 140 + want: "registry.example.com", 141 + }, 142 + { 143 + name: "domain with port", 144 + baseURL: "https://registry.example.com:443", 145 + setEnv: false, 146 + want: "registry.example.com", 147 + }, 148 + { 149 + name: "invalid URL - use default", 150 + baseURL: "://invalid", 151 + setEnv: false, 152 + want: "atcr.io", 153 + }, 154 + } 155 + 156 + for _, tt := range tests { 157 + t.Run(tt.name, func(t *testing.T) { 158 + if tt.setEnv { 159 + t.Setenv("ATCR_SERVICE_NAME", tt.envService) 160 + } else { 161 + os.Unsetenv("ATCR_SERVICE_NAME") 162 + } 163 + 164 + got := getServiceName(tt.baseURL) 165 + if got != tt.want { 166 + t.Errorf("getServiceName() = %v, want %v", got, tt.want) 167 + } 168 + }) 169 + } 170 + } 171 + 172 + func TestBuildLogConfig(t *testing.T) { 173 + tests := []struct { 174 + name string 175 + envLevel string 176 + envFormatter string 177 + setLevel bool 178 + setFormatter bool 179 + wantLevel configuration.Loglevel 180 + wantFormatter string 181 + }{ 182 + { 183 + name: "defaults", 184 + setLevel: false, 185 + setFormatter: false, 186 + wantLevel: "info", 187 + wantFormatter: "text", 188 + }, 189 + { 190 + name: "custom level", 191 + envLevel: "debug", 192 + setLevel: true, 193 + setFormatter: false, 194 + wantLevel: "debug", 195 + wantFormatter: "text", 196 + }, 197 + { 198 + name: "custom formatter", 199 + envLevel: "info", 200 + envFormatter: "json", 201 + setLevel: true, 202 + setFormatter: true, 203 + wantLevel: "info", 204 + wantFormatter: "json", 205 + }, 206 + } 207 + 208 + for _, tt := range tests { 209 + t.Run(tt.name, func(t *testing.T) { 210 + if tt.setLevel { 211 + t.Setenv("ATCR_LOG_LEVEL", tt.envLevel) 212 + } else { 213 + os.Unsetenv("ATCR_LOG_LEVEL") 214 + } 215 + 216 + if tt.setFormatter { 217 + t.Setenv("ATCR_LOG_FORMATTER", tt.envFormatter) 218 + } else { 219 + os.Unsetenv("ATCR_LOG_FORMATTER") 220 + } 221 + 222 + got := buildLogConfig() 223 + if got.Level != tt.wantLevel { 224 + t.Errorf("buildLogConfig().Level = %v, want %v", got.Level, tt.wantLevel) 225 + } 226 + if got.Formatter != tt.wantFormatter { 227 + t.Errorf("buildLogConfig().Formatter = %v, want %v", got.Formatter, tt.wantFormatter) 228 + } 229 + if got.Fields["service"] != "atcr-appview" { 230 + t.Errorf("buildLogConfig().Fields[service] = %v, want atcr-appview", got.Fields["service"]) 231 + } 232 + }) 233 + } 234 + } 235 + 236 + func TestBuildHTTPConfig(t *testing.T) { 237 + tests := []struct { 238 + name string 239 + envAddr string 240 + envDebugAddr string 241 + envSecret string 242 + setAddr bool 243 + setDebugAddr bool 244 + setSecret bool 245 + wantAddr string 246 + wantDebug string 247 + wantSecret string // empty means "should be generated" 248 + }{ 249 + { 250 + name: "defaults", 251 + setAddr: false, 252 + wantAddr: ":5000", 253 + wantDebug: ":5001", 254 + wantSecret: "", // generated 255 + }, 256 + { 257 + name: "custom addr", 258 + envAddr: ":8080", 259 + setAddr: true, 260 + setDebugAddr: false, 261 + wantAddr: ":8080", 262 + wantDebug: ":5001", 263 + wantSecret: "", 264 + }, 265 + { 266 + name: "custom debug addr", 267 + envDebugAddr: ":9001", 268 + setAddr: false, 269 + setDebugAddr: true, 270 + wantAddr: ":5000", 271 + wantDebug: ":9001", 272 + wantSecret: "", 273 + }, 274 + { 275 + name: "custom secret", 276 + envSecret: "my-custom-secret", 277 + setAddr: false, 278 + setSecret: true, 279 + wantAddr: ":5000", 280 + wantDebug: ":5001", 281 + wantSecret: "my-custom-secret", 282 + }, 283 + } 284 + 285 + for _, tt := range tests { 286 + t.Run(tt.name, func(t *testing.T) { 287 + if tt.setAddr { 288 + t.Setenv("ATCR_HTTP_ADDR", tt.envAddr) 289 + } else { 290 + os.Unsetenv("ATCR_HTTP_ADDR") 291 + } 292 + 293 + if tt.setDebugAddr { 294 + t.Setenv("ATCR_DEBUG_ADDR", tt.envDebugAddr) 295 + } else { 296 + os.Unsetenv("ATCR_DEBUG_ADDR") 297 + } 298 + 299 + if tt.setSecret { 300 + t.Setenv("REGISTRY_HTTP_SECRET", tt.envSecret) 301 + } else { 302 + os.Unsetenv("REGISTRY_HTTP_SECRET") 303 + } 304 + 305 + got, err := buildHTTPConfig() 306 + if err != nil { 307 + t.Fatalf("buildHTTPConfig() error = %v", err) 308 + } 309 + 310 + if got.Addr != tt.wantAddr { 311 + t.Errorf("buildHTTPConfig().Addr = %v, want %v", got.Addr, tt.wantAddr) 312 + } 313 + 314 + if got.Debug.Addr != tt.wantDebug { 315 + t.Errorf("buildHTTPConfig().Debug.Addr = %v, want %v", got.Debug.Addr, tt.wantDebug) 316 + } 317 + 318 + if tt.wantSecret == "" { 319 + // Should be generated (64 hex chars = 32 bytes) 320 + if len(got.Secret) != 64 { 321 + t.Errorf("buildHTTPConfig().Secret length = %v, want 64", len(got.Secret)) 322 + } 323 + } else { 324 + if got.Secret != tt.wantSecret { 325 + t.Errorf("buildHTTPConfig().Secret = %v, want %v", got.Secret, tt.wantSecret) 326 + } 327 + } 328 + 329 + // Verify headers 330 + if got.Headers["X-Content-Type-Options"][0] != "nosniff" { 331 + t.Error("buildHTTPConfig() missing X-Content-Type-Options header") 332 + } 333 + }) 334 + } 335 + } 336 + 337 + func TestBuildStorageConfig(t *testing.T) { 338 + got := buildStorageConfig() 339 + 340 + // Verify inmemory driver exists 341 + if _, ok := got["inmemory"]; !ok { 342 + t.Error("buildStorageConfig() missing inmemory driver") 343 + } 344 + 345 + // Verify maintenance config 346 + maintenance, ok := got["maintenance"] 347 + if !ok { 348 + t.Fatal("buildStorageConfig() missing maintenance config") 349 + } 350 + 351 + uploadPurging, ok := maintenance["uploadpurging"] 352 + if !ok { 353 + t.Fatal("buildStorageConfig() missing uploadpurging config") 354 + } 355 + 356 + // Verify uploadpurging is map[any]any (for distribution validation) 357 + purging, ok := uploadPurging.(map[any]any) 358 + if !ok { 359 + t.Fatalf("uploadpurging is %T, want map[any]any", uploadPurging) 360 + } 361 + 362 + if purging["enabled"] != false { 363 + t.Error("uploadpurging enabled should be false") 364 + } 365 + } 366 + 367 + func TestBuildMiddlewareConfig(t *testing.T) { 368 + tests := []struct { 369 + name string 370 + defaultHoldDID string 371 + testMode bool 372 + setTestMode bool 373 + wantTestMode bool 374 + }{ 375 + { 376 + name: "normal mode", 377 + defaultHoldDID: "did:web:hold01.atcr.io", 378 + setTestMode: false, 379 + wantTestMode: false, 380 + }, 381 + { 382 + name: "test mode enabled", 383 + defaultHoldDID: "did:web:hold01.atcr.io", 384 + testMode: true, 385 + setTestMode: true, 386 + wantTestMode: true, 387 + }, 388 + } 389 + 390 + for _, tt := range tests { 391 + t.Run(tt.name, func(t *testing.T) { 392 + if tt.setTestMode { 393 + t.Setenv("TEST_MODE", "true") 394 + } else { 395 + os.Unsetenv("TEST_MODE") 396 + } 397 + 398 + got := buildMiddlewareConfig(tt.defaultHoldDID) 399 + 400 + registryMW, ok := got["registry"] 401 + if !ok { 402 + t.Fatal("buildMiddlewareConfig() missing registry middleware") 403 + } 404 + 405 + if len(registryMW) != 1 { 406 + t.Fatalf("buildMiddlewareConfig() registry middleware count = %v, want 1", len(registryMW)) 407 + } 408 + 409 + mw := registryMW[0] 410 + if mw.Name != "atproto-resolver" { 411 + t.Errorf("middleware name = %v, want atproto-resolver", mw.Name) 412 + } 413 + 414 + if mw.Options["default_hold_did"] != tt.defaultHoldDID { 415 + t.Errorf("default_hold_did = %v, want %v", mw.Options["default_hold_did"], tt.defaultHoldDID) 416 + } 417 + 418 + if mw.Options["test_mode"] != tt.wantTestMode { 419 + t.Errorf("test_mode = %v, want %v", mw.Options["test_mode"], tt.wantTestMode) 420 + } 421 + }) 422 + } 423 + } 424 + 425 + func TestBuildAuthConfig(t *testing.T) { 426 + tests := []struct { 427 + name string 428 + baseURL string 429 + envKeyPath string 430 + envCertPath string 431 + envExpiration string 432 + setKeyPath bool 433 + setCertPath bool 434 + setExpiration bool 435 + wantKeyPath string 436 + wantCertPath string 437 + wantExpiration int 438 + wantRealm string 439 + wantService string 440 + wantError bool 441 + }{ 442 + { 443 + name: "defaults", 444 + baseURL: "http://127.0.0.1:5000", 445 + setKeyPath: false, 446 + setCertPath: false, 447 + setExpiration: false, 448 + wantKeyPath: "/var/lib/atcr/auth/private-key.pem", 449 + wantCertPath: "/var/lib/atcr/auth/private-key.crt", 450 + wantExpiration: 300, 451 + wantRealm: "http://127.0.0.1:5000/auth/token", 452 + wantService: "atcr.io", 453 + wantError: false, 454 + }, 455 + { 456 + name: "custom values", 457 + baseURL: "https://registry.example.com", 458 + envKeyPath: "/custom/key.pem", 459 + envCertPath: "/custom/cert.crt", 460 + envExpiration: "600", 461 + setKeyPath: true, 462 + setCertPath: true, 463 + setExpiration: true, 464 + wantKeyPath: "/custom/key.pem", 465 + wantCertPath: "/custom/cert.crt", 466 + wantExpiration: 600, 467 + wantRealm: "https://registry.example.com/auth/token", 468 + wantService: "registry.example.com", 469 + wantError: false, 470 + }, 471 + { 472 + name: "invalid expiration", 473 + baseURL: "http://127.0.0.1:5000", 474 + envExpiration: "not-a-number", 475 + setExpiration: true, 476 + wantError: true, 477 + }, 478 + } 479 + 480 + for _, tt := range tests { 481 + t.Run(tt.name, func(t *testing.T) { 482 + if tt.setKeyPath { 483 + t.Setenv("ATCR_AUTH_KEY_PATH", tt.envKeyPath) 484 + } else { 485 + os.Unsetenv("ATCR_AUTH_KEY_PATH") 486 + } 487 + 488 + if tt.setCertPath { 489 + t.Setenv("ATCR_AUTH_CERT_PATH", tt.envCertPath) 490 + } else { 491 + os.Unsetenv("ATCR_AUTH_CERT_PATH") 492 + } 493 + 494 + if tt.setExpiration { 495 + t.Setenv("ATCR_TOKEN_EXPIRATION", tt.envExpiration) 496 + } else { 497 + os.Unsetenv("ATCR_TOKEN_EXPIRATION") 498 + } 499 + 500 + // Clear service name env var 501 + os.Unsetenv("ATCR_SERVICE_NAME") 502 + 503 + got, err := buildAuthConfig(tt.baseURL) 504 + if (err != nil) != tt.wantError { 505 + t.Errorf("buildAuthConfig() error = %v, wantError %v", err, tt.wantError) 506 + return 507 + } 508 + 509 + if tt.wantError { 510 + return 511 + } 512 + 513 + tokenParams, ok := got["token"] 514 + if !ok { 515 + t.Fatal("buildAuthConfig() missing token params") 516 + } 517 + 518 + if tokenParams["privatekey"] != tt.wantKeyPath { 519 + t.Errorf("privatekey = %v, want %v", tokenParams["privatekey"], tt.wantKeyPath) 520 + } 521 + 522 + if tokenParams["rootcertbundle"] != tt.wantCertPath { 523 + t.Errorf("rootcertbundle = %v, want %v", tokenParams["rootcertbundle"], tt.wantCertPath) 524 + } 525 + 526 + if tokenParams["expiration"] != tt.wantExpiration { 527 + t.Errorf("expiration = %v, want %v", tokenParams["expiration"], tt.wantExpiration) 528 + } 529 + 530 + if tokenParams["realm"] != tt.wantRealm { 531 + t.Errorf("realm = %v, want %v", tokenParams["realm"], tt.wantRealm) 532 + } 533 + 534 + if tokenParams["service"] != tt.wantService { 535 + t.Errorf("service = %v, want %v", tokenParams["service"], tt.wantService) 536 + } 537 + 538 + if tokenParams["issuer"] != tt.wantService { 539 + t.Errorf("issuer = %v, want %v", tokenParams["issuer"], tt.wantService) 540 + } 541 + }) 542 + } 543 + } 544 + 545 + func TestBuildHealthConfig(t *testing.T) { 546 + got := buildHealthConfig() 547 + 548 + if !got.StorageDriver.Enabled { 549 + t.Error("buildHealthConfig().StorageDriver.Enabled = false, want true") 550 + } 551 + 552 + if got.StorageDriver.Interval.Seconds() != 10 { 553 + t.Errorf("buildHealthConfig().StorageDriver.Interval = %v, want 10s", got.StorageDriver.Interval) 554 + } 555 + 556 + if got.StorageDriver.Threshold != 3 { 557 + t.Errorf("buildHealthConfig().StorageDriver.Threshold = %v, want 3", got.StorageDriver.Threshold) 558 + } 559 + } 560 + 561 + func TestGetStringParam(t *testing.T) { 562 + tests := []struct { 563 + name string 564 + params configuration.Parameters 565 + key string 566 + defaultValue string 567 + want string 568 + }{ 569 + { 570 + name: "string value exists", 571 + params: configuration.Parameters{ 572 + "foo": "bar", 573 + }, 574 + key: "foo", 575 + defaultValue: "default", 576 + want: "bar", 577 + }, 578 + { 579 + name: "key does not exist", 580 + params: configuration.Parameters{}, 581 + key: "foo", 582 + defaultValue: "default", 583 + want: "default", 584 + }, 585 + { 586 + name: "value is not a string", 587 + params: configuration.Parameters{ 588 + "foo": 123, 589 + }, 590 + key: "foo", 591 + defaultValue: "default", 592 + want: "default", 593 + }, 594 + { 595 + name: "empty string value", 596 + params: configuration.Parameters{ 597 + "foo": "", 598 + }, 599 + key: "foo", 600 + defaultValue: "default", 601 + want: "", 602 + }, 603 + } 604 + 605 + for _, tt := range tests { 606 + t.Run(tt.name, func(t *testing.T) { 607 + got := GetStringParam(tt.params, tt.key, tt.defaultValue) 608 + if got != tt.want { 609 + t.Errorf("GetStringParam() = %v, want %v", got, tt.want) 610 + } 611 + }) 612 + } 613 + } 614 + 615 + func TestGetIntParam(t *testing.T) { 616 + tests := []struct { 617 + name string 618 + params configuration.Parameters 619 + key string 620 + defaultValue int 621 + want int 622 + }{ 623 + { 624 + name: "int value exists", 625 + params: configuration.Parameters{ 626 + "foo": 42, 627 + }, 628 + key: "foo", 629 + defaultValue: 100, 630 + want: 42, 631 + }, 632 + { 633 + name: "key does not exist", 634 + params: configuration.Parameters{}, 635 + key: "foo", 636 + defaultValue: 100, 637 + want: 100, 638 + }, 639 + { 640 + name: "value is not an int", 641 + params: configuration.Parameters{ 642 + "foo": "not-an-int", 643 + }, 644 + key: "foo", 645 + defaultValue: 100, 646 + want: 100, 647 + }, 648 + { 649 + name: "zero value", 650 + params: configuration.Parameters{ 651 + "foo": 0, 652 + }, 653 + key: "foo", 654 + defaultValue: 100, 655 + want: 0, 656 + }, 657 + } 658 + 659 + for _, tt := range tests { 660 + t.Run(tt.name, func(t *testing.T) { 661 + got := GetIntParam(tt.params, tt.key, tt.defaultValue) 662 + if got != tt.want { 663 + t.Errorf("GetIntParam() = %v, want %v", got, tt.want) 664 + } 665 + }) 666 + } 667 + } 668 + 669 + func TestExtractDefaultHoldDID(t *testing.T) { 670 + tests := []struct { 671 + name string 672 + config *configuration.Configuration 673 + want string 674 + }{ 675 + { 676 + name: "valid config with hold DID", 677 + config: &configuration.Configuration{ 678 + Middleware: map[string][]configuration.Middleware{ 679 + "registry": { 680 + { 681 + Name: "atproto-resolver", 682 + Options: configuration.Parameters{ 683 + "default_hold_did": "did:web:hold01.atcr.io", 684 + }, 685 + }, 686 + }, 687 + }, 688 + }, 689 + want: "did:web:hold01.atcr.io", 690 + }, 691 + { 692 + name: "no registry middleware", 693 + config: &configuration.Configuration{ 694 + Middleware: map[string][]configuration.Middleware{}, 695 + }, 696 + want: "", 697 + }, 698 + { 699 + name: "no atproto-resolver middleware", 700 + config: &configuration.Configuration{ 701 + Middleware: map[string][]configuration.Middleware{ 702 + "registry": { 703 + { 704 + Name: "other-middleware", 705 + Options: configuration.Parameters{ 706 + "foo": "bar", 707 + }, 708 + }, 709 + }, 710 + }, 711 + }, 712 + want: "", 713 + }, 714 + { 715 + name: "atproto-resolver without default_hold_did", 716 + config: &configuration.Configuration{ 717 + Middleware: map[string][]configuration.Middleware{ 718 + "registry": { 719 + { 720 + Name: "atproto-resolver", 721 + Options: configuration.Parameters{ 722 + "other_option": "value", 723 + }, 724 + }, 725 + }, 726 + }, 727 + }, 728 + want: "", 729 + }, 730 + { 731 + name: "default_hold_did is not a string", 732 + config: &configuration.Configuration{ 733 + Middleware: map[string][]configuration.Middleware{ 734 + "registry": { 735 + { 736 + Name: "atproto-resolver", 737 + Options: configuration.Parameters{ 738 + "default_hold_did": 123, 739 + }, 740 + }, 741 + }, 742 + }, 743 + }, 744 + want: "", 745 + }, 746 + { 747 + name: "nil options", 748 + config: &configuration.Configuration{ 749 + Middleware: map[string][]configuration.Middleware{ 750 + "registry": { 751 + { 752 + Name: "atproto-resolver", 753 + Options: nil, 754 + }, 755 + }, 756 + }, 757 + }, 758 + want: "", 759 + }, 760 + } 761 + 762 + for _, tt := range tests { 763 + t.Run(tt.name, func(t *testing.T) { 764 + got := ExtractDefaultHoldDID(tt.config) 765 + if got != tt.want { 766 + t.Errorf("ExtractDefaultHoldDID() = %v, want %v", got, tt.want) 767 + } 768 + }) 769 + } 770 + } 771 + 772 + func TestLoadConfigFromEnv(t *testing.T) { 773 + tests := []struct { 774 + name string 775 + envHoldDID string 776 + setHoldDID bool 777 + wantError bool 778 + }{ 779 + { 780 + name: "valid config", 781 + envHoldDID: "did:web:hold01.atcr.io", 782 + setHoldDID: true, 783 + wantError: false, 784 + }, 785 + { 786 + name: "missing default hold DID", 787 + setHoldDID: false, 788 + wantError: true, 789 + }, 790 + } 791 + 792 + for _, tt := range tests { 793 + t.Run(tt.name, func(t *testing.T) { 794 + if tt.setHoldDID { 795 + t.Setenv("ATCR_DEFAULT_HOLD_DID", tt.envHoldDID) 796 + } else { 797 + os.Unsetenv("ATCR_DEFAULT_HOLD_DID") 798 + } 799 + 800 + // Clear other env vars to use defaults 801 + os.Unsetenv("ATCR_BASE_URL") 802 + os.Unsetenv("ATCR_SERVICE_NAME") 803 + 804 + got, err := LoadConfigFromEnv() 805 + if (err != nil) != tt.wantError { 806 + t.Errorf("LoadConfigFromEnv() error = %v, wantError %v", err, tt.wantError) 807 + return 808 + } 809 + 810 + if tt.wantError { 811 + return 812 + } 813 + 814 + // Verify config structure 815 + if got.Version.Major() != 0 || got.Version.Minor() != 1 { 816 + t.Errorf("version = %v, want 0.1", got.Version) 817 + } 818 + 819 + if got.Log.Level != "info" { 820 + t.Errorf("log level = %v, want info", got.Log.Level) 821 + } 822 + 823 + if got.HTTP.Addr != ":5000" { 824 + t.Errorf("HTTP addr = %v, want :5000", got.HTTP.Addr) 825 + } 826 + 827 + if _, ok := got.Storage["inmemory"]; !ok { 828 + t.Error("storage missing inmemory driver") 829 + } 830 + 831 + if _, ok := got.Middleware["registry"]; !ok { 832 + t.Error("middleware missing registry") 833 + } 834 + 835 + if _, ok := got.Auth["token"]; !ok { 836 + t.Error("auth missing token config") 837 + } 838 + 839 + if !got.Health.StorageDriver.Enabled { 840 + t.Error("health storage driver not enabled") 841 + } 842 + }) 843 + } 844 + }
+115
pkg/appview/db/readonly.go
··· 1 + package db 2 + 3 + import ( 4 + "context" 5 + "database/sql" 6 + "fmt" 7 + "os" 8 + "path/filepath" 9 + "time" 10 + 11 + sqlite3 "github.com/mattn/go-sqlite3" 12 + ) 13 + 14 + const ( 15 + // ReadOnlyDriverName is the name of the custom SQLite driver with table authorization 16 + ReadOnlyDriverName = "sqlite3_readonly_public" 17 + ) 18 + 19 + // sensitiveTables defines tables that should never be accessible from public queries 20 + var sensitiveTables = map[string]bool{ 21 + "oauth_sessions": true, // OAuth tokens 22 + "ui_sessions": true, // Session IDs 23 + "oauth_auth_requests": true, // OAuth state 24 + "devices": true, // Device secret hashes 25 + "pending_device_auth": true, // Pending device secrets 26 + } 27 + 28 + // readOnlyAuthorizerCallback blocks access to sensitive tables 29 + func readOnlyAuthorizerCallback(action int, arg1, arg2, dbName string) int { 30 + // arg1 contains the table name for most operations 31 + tableName := arg1 32 + 33 + // Block any access to sensitive tables 34 + if action == sqlite3.SQLITE_READ || action == sqlite3.SQLITE_UPDATE || 35 + action == sqlite3.SQLITE_INSERT || action == sqlite3.SQLITE_DELETE || 36 + action == sqlite3.SQLITE_SELECT { 37 + if sensitiveTables[tableName] { 38 + fmt.Printf("SECURITY: Blocked access to sensitive table '%s' (action=%d)\n", tableName, action) 39 + return sqlite3.SQLITE_DENY 40 + } 41 + } 42 + 43 + // Allow everything else 44 + return sqlite3.SQLITE_OK 45 + } 46 + 47 + func init() { 48 + // Register a custom SQLite driver with authorizer for read-only public queries 49 + sql.Register(ReadOnlyDriverName, 50 + &sqlite3.SQLiteDriver{ 51 + ConnectHook: func(conn *sqlite3.SQLiteConn) error { 52 + conn.RegisterAuthorizer(readOnlyAuthorizerCallback) 53 + return nil 54 + }, 55 + }) 56 + } 57 + 58 + // InitializeDatabase initializes the SQLite database and session store 59 + // Returns: (read-write DB, read-only DB, session store) 60 + func InitializeDatabase(uiEnabled bool, dbPath string) (*sql.DB, *sql.DB, *SessionStore) { 61 + if !uiEnabled { 62 + return nil, nil, nil 63 + } 64 + 65 + // Ensure directory exists 66 + dbDir := filepath.Dir(dbPath) 67 + if err := os.MkdirAll(dbDir, 0700); err != nil { 68 + fmt.Printf("Warning: Failed to create UI database directory: %v\n", err) 69 + return nil, nil, nil 70 + } 71 + 72 + // Initialize read-write database (for writes and auth operations) 73 + database, err := InitDB(dbPath) 74 + if err != nil { 75 + fmt.Printf("Warning: Failed to initialize UI database: %v\n", err) 76 + return nil, nil, nil 77 + } 78 + 79 + // Open read-only connection for public queries (search, user pages, etc.) 80 + // Uses custom driver with SQLite authorizer that blocks sensitive tables 81 + // This prevents accidental writes and blocks access to sensitive tables even if SQL injection occurs 82 + readOnlyDB, err := sql.Open(ReadOnlyDriverName, "file:"+dbPath+"?mode=ro") 83 + if err != nil { 84 + fmt.Printf("Warning: Failed to open read-only database connection: %v\n", err) 85 + return nil, nil, nil 86 + } 87 + 88 + fmt.Printf("UI database (readonly) initialized at %s\n", dbPath) 89 + 90 + // Create SQLite-backed session store 91 + sessionStore := NewSessionStore(database) 92 + 93 + // Start cleanup goroutines for all SQLite stores 94 + go func() { 95 + ticker := time.NewTicker(5 * time.Minute) 96 + defer ticker.Stop() 97 + for range ticker.C { 98 + ctx := context.Background() 99 + 100 + // Cleanup UI sessions 101 + sessionStore.Cleanup() 102 + 103 + // Cleanup OAuth sessions (older than 30 days) 104 + oauthStore := NewOAuthStore(database) 105 + oauthStore.CleanupOldSessions(ctx, 30*24*time.Hour) 106 + oauthStore.CleanupExpiredAuthRequests(ctx) 107 + 108 + // Cleanup device pending auths 109 + deviceStore := NewDeviceStore(database) 110 + deviceStore.CleanupExpired() 111 + } 112 + }() 113 + 114 + return database, readOnlyDB, sessionStore 115 + }
+9
pkg/appview/handlers/common.go
··· 2 2 3 3 import ( 4 4 "net/http" 5 + "strings" 5 6 6 7 "atcr.io/pkg/appview/db" 7 8 "atcr.io/pkg/appview/middleware" ··· 22 23 RegistryURL: registryURL, 23 24 } 24 25 } 26 + 27 + // TrimRegistryURL removes http:// or https:// prefix from a URL 28 + // for use in Docker commands where only the host:port is needed 29 + func TrimRegistryURL(url string) string { 30 + url = strings.TrimPrefix(url, "https://") 31 + url = strings.TrimPrefix(url, "http://") 32 + return url 33 + }
-11
pkg/appview/handlers/util.go
··· 1 - package handlers 2 - 3 - import "strings" 4 - 5 - // TrimRegistryURL removes http:// or https:// prefix from a URL 6 - // for use in Docker commands where only the host:port is needed 7 - func TrimRegistryURL(url string) string { 8 - url = strings.TrimPrefix(url, "https://") 9 - url = strings.TrimPrefix(url, "http://") 10 - return url 11 - }
+4 -4
pkg/appview/storage/proxy_blob_store.go
··· 76 76 77 77 // Use HTTP for localhost/IP addresses with ports, HTTPS for domains 78 78 if strings.Contains(hostname, ":") || 79 - strings.Contains(hostname, "127.0.0.1") || 80 - strings.Contains(hostname, "localhost") || 81 - // Check if it's an IP address (contains only digits and dots) 82 - (len(hostname) > 0 && (hostname[0] >= '0' && hostname[0] <= '9')) { 79 + strings.Contains(hostname, "127.0.0.1") || 80 + strings.Contains(hostname, "localhost") || 81 + // Check if it's an IP address (contains only digits and dots) 82 + (len(hostname) > 0 && (hostname[0] >= '0' && hostname[0] <= '9')) { 83 83 return "http://" + hostname 84 84 } 85 85 return "https://" + hostname
+601
pkg/appview/ui_test.go
··· 1 + package appview 2 + 3 + import ( 4 + "bytes" 5 + "strings" 6 + "testing" 7 + "time" 8 + ) 9 + 10 + func TestTimeAgo(t *testing.T) { 11 + now := time.Now() 12 + 13 + tests := []struct { 14 + name string 15 + time time.Time 16 + expected string 17 + }{ 18 + { 19 + name: "just now - 30 seconds ago", 20 + time: now.Add(-30 * time.Second), 21 + expected: "just now", 22 + }, 23 + { 24 + name: "1 minute ago", 25 + time: now.Add(-1 * time.Minute), 26 + expected: "1 minute ago", 27 + }, 28 + { 29 + name: "5 minutes ago", 30 + time: now.Add(-5 * time.Minute), 31 + expected: "5 minutes ago", 32 + }, 33 + { 34 + name: "45 minutes ago", 35 + time: now.Add(-45 * time.Minute), 36 + expected: "45 minutes ago", 37 + }, 38 + { 39 + name: "1 hour ago", 40 + time: now.Add(-1 * time.Hour), 41 + expected: "1 hour ago", 42 + }, 43 + { 44 + name: "3 hours ago", 45 + time: now.Add(-3 * time.Hour), 46 + expected: "3 hours ago", 47 + }, 48 + { 49 + name: "23 hours ago", 50 + time: now.Add(-23 * time.Hour), 51 + expected: "23 hours ago", 52 + }, 53 + { 54 + name: "1 day ago", 55 + time: now.Add(-24 * time.Hour), 56 + expected: "1 day ago", 57 + }, 58 + { 59 + name: "5 days ago", 60 + time: now.Add(-5 * 24 * time.Hour), 61 + expected: "5 days ago", 62 + }, 63 + { 64 + name: "30 days ago", 65 + time: now.Add(-30 * 24 * time.Hour), 66 + expected: "30 days ago", 67 + }, 68 + } 69 + 70 + for _, tt := range tests { 71 + t.Run(tt.name, func(t *testing.T) { 72 + // Get fresh template for each test case 73 + tmpl, err := Templates() 74 + if err != nil { 75 + t.Fatalf("Templates() error = %v", err) 76 + } 77 + 78 + // Execute template using timeAgo function 79 + templateStr := `{{ timeAgo . }}` 80 + buf := new(bytes.Buffer) 81 + temp, err := tmpl.New("test").Parse(templateStr) 82 + if err != nil { 83 + t.Fatalf("Failed to parse template: %v", err) 84 + } 85 + 86 + err = temp.Execute(buf, tt.time) 87 + if err != nil { 88 + t.Fatalf("Failed to execute template: %v", err) 89 + } 90 + 91 + got := buf.String() 92 + if got != tt.expected { 93 + t.Errorf("timeAgo() = %q, want %q", got, tt.expected) 94 + } 95 + }) 96 + } 97 + } 98 + 99 + func TestHumanizeBytes(t *testing.T) { 100 + tests := []struct { 101 + name string 102 + bytes int64 103 + expected string 104 + }{ 105 + { 106 + name: "0 bytes", 107 + bytes: 0, 108 + expected: "0 B", 109 + }, 110 + { 111 + name: "512 bytes", 112 + bytes: 512, 113 + expected: "512 B", 114 + }, 115 + { 116 + name: "1023 bytes", 117 + bytes: 1023, 118 + expected: "1023 B", 119 + }, 120 + { 121 + name: "1 KB", 122 + bytes: 1024, 123 + expected: "1.0 KB", 124 + }, 125 + { 126 + name: "1.5 KB", 127 + bytes: 1536, 128 + expected: "1.5 KB", 129 + }, 130 + { 131 + name: "1 MB", 132 + bytes: 1024 * 1024, 133 + expected: "1.0 MB", 134 + }, 135 + { 136 + name: "2.5 MB", 137 + bytes: 2621440, // 2.5 * 1024 * 1024 138 + expected: "2.5 MB", 139 + }, 140 + { 141 + name: "1 GB", 142 + bytes: 1024 * 1024 * 1024, 143 + expected: "1.0 GB", 144 + }, 145 + { 146 + name: "5.2 GB", 147 + bytes: 5583457485, // ~5.2 GB 148 + expected: "5.2 GB", 149 + }, 150 + { 151 + name: "1 TB", 152 + bytes: 1024 * 1024 * 1024 * 1024, 153 + expected: "1.0 TB", 154 + }, 155 + { 156 + name: "1.5 PB", 157 + bytes: 1688849860263936, // 1.5 PB 158 + expected: "1.5 PB", 159 + }, 160 + } 161 + 162 + for _, tt := range tests { 163 + t.Run(tt.name, func(t *testing.T) { 164 + // Get fresh template for each test case 165 + tmpl, err := Templates() 166 + if err != nil { 167 + t.Fatalf("Templates() error = %v", err) 168 + } 169 + 170 + templateStr := `{{ humanizeBytes . }}` 171 + buf := new(bytes.Buffer) 172 + temp, err := tmpl.New("test").Parse(templateStr) 173 + if err != nil { 174 + t.Fatalf("Failed to parse template: %v", err) 175 + } 176 + 177 + err = temp.Execute(buf, tt.bytes) 178 + if err != nil { 179 + t.Fatalf("Failed to execute template: %v", err) 180 + } 181 + 182 + got := buf.String() 183 + if got != tt.expected { 184 + t.Errorf("humanizeBytes(%d) = %q, want %q", tt.bytes, got, tt.expected) 185 + } 186 + }) 187 + } 188 + } 189 + 190 + func TestTruncateDigest(t *testing.T) { 191 + tests := []struct { 192 + name string 193 + digest string 194 + length int 195 + expected string 196 + }{ 197 + { 198 + name: "short digest - no truncation needed", 199 + digest: "sha256:abc", 200 + length: 20, 201 + expected: "sha256:abc", 202 + }, 203 + { 204 + name: "truncate to 12 chars", 205 + digest: "sha256:abcdef123456789", 206 + length: 12, 207 + expected: "sha256:abcde...", 208 + }, 209 + { 210 + name: "truncate to 8 chars", 211 + digest: "sha256:1234567890abcdef", 212 + length: 8, 213 + expected: "sha256:1...", 214 + }, 215 + { 216 + name: "exact length match", 217 + digest: "sha256:abc", 218 + length: 10, 219 + expected: "sha256:abc", 220 + }, 221 + { 222 + name: "empty digest", 223 + digest: "", 224 + length: 10, 225 + expected: "", 226 + }, 227 + { 228 + name: "long sha256 digest", 229 + digest: "sha256:f1c8f6a4b7e9d2c0a3f5b8e1d4c7a0b3e6f9c2d5a8b1e4f7c0d3a6b9e2f5c8a1", 230 + length: 16, 231 + expected: "sha256:f1c8f6a4b...", 232 + }, 233 + } 234 + 235 + for _, tt := range tests { 236 + t.Run(tt.name, func(t *testing.T) { 237 + // Get fresh template for each test case 238 + tmpl, err := Templates() 239 + if err != nil { 240 + t.Fatalf("Templates() error = %v", err) 241 + } 242 + 243 + templateStr := `{{ truncateDigest .Digest .Length }}` 244 + buf := new(bytes.Buffer) 245 + temp, err := tmpl.New("test").Parse(templateStr) 246 + if err != nil { 247 + t.Fatalf("Failed to parse template: %v", err) 248 + } 249 + 250 + data := struct { 251 + Digest string 252 + Length int 253 + }{ 254 + Digest: tt.digest, 255 + Length: tt.length, 256 + } 257 + 258 + err = temp.Execute(buf, data) 259 + if err != nil { 260 + t.Fatalf("Failed to execute template: %v", err) 261 + } 262 + 263 + got := buf.String() 264 + if got != tt.expected { 265 + t.Errorf("truncateDigest(%q, %d) = %q, want %q", tt.digest, tt.length, got, tt.expected) 266 + } 267 + }) 268 + } 269 + } 270 + 271 + func TestFirstChar(t *testing.T) { 272 + tests := []struct { 273 + name string 274 + input string 275 + expected string 276 + }{ 277 + { 278 + name: "normal string", 279 + input: "hello", 280 + expected: "h", 281 + }, 282 + { 283 + name: "uppercase", 284 + input: "World", 285 + expected: "W", 286 + }, 287 + { 288 + name: "single character", 289 + input: "a", 290 + expected: "a", 291 + }, 292 + { 293 + name: "empty string", 294 + input: "", 295 + expected: "?", 296 + }, 297 + { 298 + name: "unicode character", 299 + input: "😀 emoji", 300 + expected: "😀", 301 + }, 302 + { 303 + name: "chinese character", 304 + input: "你好", 305 + expected: "你", 306 + }, 307 + { 308 + name: "number", 309 + input: "123", 310 + expected: "1", 311 + }, 312 + { 313 + name: "special character", 314 + input: "@user", 315 + expected: "@", 316 + }, 317 + } 318 + 319 + for _, tt := range tests { 320 + t.Run(tt.name, func(t *testing.T) { 321 + // Get fresh template for each test case 322 + tmpl, err := Templates() 323 + if err != nil { 324 + t.Fatalf("Templates() error = %v", err) 325 + } 326 + 327 + templateStr := `{{ firstChar . }}` 328 + buf := new(bytes.Buffer) 329 + temp, err := tmpl.New("test").Parse(templateStr) 330 + if err != nil { 331 + t.Fatalf("Failed to parse template: %v", err) 332 + } 333 + 334 + err = temp.Execute(buf, tt.input) 335 + if err != nil { 336 + t.Fatalf("Failed to execute template: %v", err) 337 + } 338 + 339 + got := buf.String() 340 + if got != tt.expected { 341 + t.Errorf("firstChar(%q) = %q, want %q", tt.input, got, tt.expected) 342 + } 343 + }) 344 + } 345 + } 346 + 347 + func TestTrimPrefix(t *testing.T) { 348 + tests := []struct { 349 + name string 350 + prefix string 351 + input string 352 + expected string 353 + }{ 354 + { 355 + name: "trim sha256 prefix", 356 + prefix: "sha256:", 357 + input: "sha256:abcdef123456", 358 + expected: "abcdef123456", 359 + }, 360 + { 361 + name: "no prefix match", 362 + prefix: "sha256:", 363 + input: "md5:abcdef123456", 364 + expected: "md5:abcdef123456", 365 + }, 366 + { 367 + name: "empty prefix", 368 + prefix: "", 369 + input: "hello", 370 + expected: "hello", 371 + }, 372 + { 373 + name: "empty string", 374 + prefix: "prefix:", 375 + input: "", 376 + expected: "", 377 + }, 378 + { 379 + name: "prefix longer than string", 380 + prefix: "very-long-prefix", 381 + input: "short", 382 + expected: "short", 383 + }, 384 + { 385 + name: "exact match", 386 + prefix: "prefix", 387 + input: "prefix", 388 + expected: "", 389 + }, 390 + { 391 + name: "partial prefix match", 392 + prefix: "sha256:", 393 + input: "sha25", 394 + expected: "sha25", 395 + }, 396 + { 397 + name: "trim docker.io prefix", 398 + prefix: "docker.io/", 399 + input: "docker.io/library/alpine", 400 + expected: "library/alpine", 401 + }, 402 + } 403 + 404 + for _, tt := range tests { 405 + t.Run(tt.name, func(t *testing.T) { 406 + // Get fresh template for each test case 407 + tmpl, err := Templates() 408 + if err != nil { 409 + t.Fatalf("Templates() error = %v", err) 410 + } 411 + 412 + templateStr := `{{ trimPrefix .Prefix .Input }}` 413 + buf := new(bytes.Buffer) 414 + temp, err := tmpl.New("test").Parse(templateStr) 415 + if err != nil { 416 + t.Fatalf("Failed to parse template: %v", err) 417 + } 418 + 419 + data := struct { 420 + Prefix string 421 + Input string 422 + }{ 423 + Prefix: tt.prefix, 424 + Input: tt.input, 425 + } 426 + 427 + err = temp.Execute(buf, data) 428 + if err != nil { 429 + t.Fatalf("Failed to execute template: %v", err) 430 + } 431 + 432 + got := buf.String() 433 + if got != tt.expected { 434 + t.Errorf("trimPrefix(%q, %q) = %q, want %q", tt.prefix, tt.input, got, tt.expected) 435 + } 436 + }) 437 + } 438 + } 439 + 440 + func TestTemplates(t *testing.T) { 441 + tmpl, err := Templates() 442 + if err != nil { 443 + t.Fatalf("Templates() error = %v", err) 444 + } 445 + 446 + if tmpl == nil { 447 + t.Fatal("Templates() returned nil template") 448 + } 449 + 450 + // Test that all expected templates are loaded 451 + expectedTemplates := []string{ 452 + "base.html", 453 + "nav", 454 + "repo-card", 455 + "repository", 456 + "home.html", 457 + "search.html", 458 + "user.html", 459 + "login.html", 460 + "settings.html", 461 + "install.html", 462 + "manifest-modal", 463 + "push-list.html", 464 + } 465 + 466 + for _, name := range expectedTemplates { 467 + t.Run("template_"+name, func(t *testing.T) { 468 + temp := tmpl.Lookup(name) 469 + if temp == nil { 470 + t.Errorf("Expected template %q not found", name) 471 + } 472 + }) 473 + } 474 + } 475 + 476 + func TestTemplateExecution_RepoCard(t *testing.T) { 477 + tmpl, err := Templates() 478 + if err != nil { 479 + t.Fatalf("Templates() error = %v", err) 480 + } 481 + 482 + // Sample data for repo-card template 483 + data := struct { 484 + OwnerHandle string 485 + Repository string 486 + IconURL string 487 + Description string 488 + StarCount int 489 + PullCount int 490 + }{ 491 + OwnerHandle: "alice.bsky.social", 492 + Repository: "myapp", 493 + IconURL: "", 494 + Description: "A cool container image", 495 + StarCount: 42, 496 + PullCount: 1337, 497 + } 498 + 499 + buf := new(bytes.Buffer) 500 + err = tmpl.ExecuteTemplate(buf, "repo-card", data) 501 + if err != nil { 502 + t.Fatalf("Failed to execute repo-card template: %v", err) 503 + } 504 + 505 + output := buf.String() 506 + 507 + // Verify expected content in output 508 + expectedContent := []string{ 509 + "alice.bsky.social", 510 + "myapp", 511 + "A cool container image", 512 + "42", // star count 513 + "1337", // pull count 514 + "featured-icon-placeholder", // no icon URL provided 515 + } 516 + 517 + for _, expected := range expectedContent { 518 + if !strings.Contains(output, expected) { 519 + t.Errorf("Template output missing expected content %q", expected) 520 + } 521 + } 522 + 523 + // Verify firstChar function is working 524 + if !strings.Contains(output, ">m<") { // first char of "myapp" 525 + t.Error("Template output missing firstChar result") 526 + } 527 + } 528 + 529 + func TestTemplateExecution_WithFuncMap(t *testing.T) { 530 + // Test that templates can use FuncMap functions 531 + tests := []struct { 532 + name string 533 + templateStr string 534 + data interface{} 535 + expectInOutput string 536 + }{ 537 + { 538 + name: "timeAgo in template", 539 + templateStr: `{{ define "test1" }}{{ timeAgo . }}{{ end }}`, 540 + data: time.Now().Add(-5 * time.Minute), 541 + expectInOutput: "5 minutes ago", 542 + }, 543 + { 544 + name: "humanizeBytes in template", 545 + templateStr: `{{ define "test2" }}{{ humanizeBytes . }}{{ end }}`, 546 + data: int64(1024 * 1024 * 10), // 10 MB 547 + expectInOutput: "10.0 MB", 548 + }, 549 + { 550 + name: "multiple functions in template", 551 + templateStr: `{{ define "test3" }}{{ truncateDigest .Digest 12 }} - {{ firstChar .Name }}{{ end }}`, 552 + data: struct { 553 + Digest string 554 + Name string 555 + }{ 556 + Digest: "sha256:abcdef1234567890", 557 + Name: "myapp", 558 + }, 559 + expectInOutput: "sha256:abcde... - m", 560 + }, 561 + } 562 + 563 + for _, tt := range tests { 564 + t.Run(tt.name, func(t *testing.T) { 565 + // Get fresh template for each test case 566 + tmpl, err := Templates() 567 + if err != nil { 568 + t.Fatalf("Templates() error = %v", err) 569 + } 570 + 571 + temp, err := tmpl.Parse(tt.templateStr) 572 + if err != nil { 573 + t.Fatalf("Failed to parse template: %v", err) 574 + } 575 + 576 + buf := new(bytes.Buffer) 577 + // Extract the template name from the define 578 + templateName := strings.Split(strings.TrimPrefix(tt.templateStr, `{{ define "`), `"`)[0] 579 + err = temp.ExecuteTemplate(buf, templateName, tt.data) 580 + if err != nil { 581 + t.Fatalf("Failed to execute template: %v", err) 582 + } 583 + 584 + output := buf.String() 585 + if !strings.Contains(output, tt.expectInOutput) { 586 + t.Errorf("Template output %q does not contain expected %q", output, tt.expectInOutput) 587 + } 588 + }) 589 + } 590 + } 591 + 592 + func TestStaticHandler(t *testing.T) { 593 + handler := StaticHandler() 594 + if handler == nil { 595 + t.Fatal("StaticHandler() returned nil") 596 + } 597 + 598 + // Test that it returns an http.Handler 599 + // Further testing would require HTTP request/response testing 600 + // which is typically done in integration tests 601 + }
+5 -55
pkg/atproto/lexicon.go
··· 1 1 package atproto 2 2 3 - //go:generate go run github.com/whyrusleeping/cbor-gen --map-encoding CrewRecord CaptainRecord 4 - 5 3 import ( 6 4 "encoding/base64" 7 5 "encoding/json" ··· 221 219 } 222 220 } 223 221 224 - // HoldCrewRecord represents membership in a storage hold 225 - // Stored in the hold owner's PDS (not the crew member's PDS) to ensure owner maintains full control 226 - // Owner can add/remove crew members by creating/deleting these records in their own PDS 227 - // Supports both explicit DIDs (with backlinks) and pattern-based matching (wildcards, handle globs) 228 - type HoldCrewRecord struct { 229 - // Type should be "io.atcr.hold.crew" 230 - Type string `json:"$type"` 231 - 232 - // Hold is the AT URI of the hold record 233 - // e.g., "at://did:plc:owner/io.atcr.hold/hold1" 234 - Hold string `json:"hold"` 235 - 236 - // Member is the DID of the crew member (optional, for explicit access) 237 - // Exactly one of Member or MemberPattern must be set 238 - Member *string `json:"member,omitempty"` 239 - 240 - // MemberPattern is a pattern for matching multiple users (optional, for pattern-based access) 241 - // Supports wildcards: "*" (all users), "*.domain.com" (handle glob) 242 - // Exactly one of Member or MemberPattern must be set 243 - MemberPattern *string `json:"memberPattern,omitempty"` 244 - 245 - // Role defines permissions: "owner", "write", "read" 246 - Role string `json:"role"` 247 - 248 - // ExpiresAt is optional expiration for this membership 249 - ExpiresAt *time.Time `json:"expiresAt,omitempty"` 250 - 251 - // AddedAt timestamp 252 - AddedAt time.Time `json:"createdAt"` 253 - } 254 - 255 - // NewHoldCrewRecord creates a new hold crew record with explicit DID 256 - func NewHoldCrewRecord(hold, member, role string) *HoldCrewRecord { 257 - return &HoldCrewRecord{ 258 - Type: HoldCrewCollection, 259 - Hold: hold, 260 - Member: &member, 261 - Role: role, 262 - AddedAt: time.Now(), 263 - } 264 - } 265 - 266 - // NewHoldCrewRecordWithPattern creates a new hold crew record with pattern matching 267 - func NewHoldCrewRecordWithPattern(hold, pattern, role string) *HoldCrewRecord { 268 - return &HoldCrewRecord{ 269 - Type: HoldCrewCollection, 270 - Hold: hold, 271 - MemberPattern: &pattern, 272 - Role: role, 273 - AddedAt: time.Now(), 274 - } 275 - } 276 - 277 222 // SailorProfileRecord represents a user's profile with registry preferences 278 223 // Stored in the user's PDS to configure default hold and other settings 279 224 type SailorProfileRecord struct { ··· 388 333 // Convert to did:web 389 334 // did:web uses hostname directly (port included if non-standard) 390 335 return "did:web:" + hostname 336 + } 337 + 338 + // isDID checks if a string is a DID (starts with "did:") 339 + func isDID(s string) bool { 340 + return len(s) > 4 && s[:4] == "did:" 391 341 } 392 342 393 343 // =============================================================================
+683
pkg/atproto/lexicon_test.go
··· 1 + package atproto 2 + 3 + import ( 4 + "encoding/json" 5 + "strings" 6 + "testing" 7 + "time" 8 + ) 9 + 10 + func TestNewManifestRecord(t *testing.T) { 11 + validOCIManifest := `{ 12 + "schemaVersion": 2, 13 + "mediaType": "application/vnd.oci.image.manifest.v1+json", 14 + "config": { 15 + "mediaType": "application/vnd.oci.image.config.v1+json", 16 + "digest": "sha256:config123", 17 + "size": 1234 18 + }, 19 + "layers": [ 20 + { 21 + "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", 22 + "digest": "sha256:layer1", 23 + "size": 5678 24 + }, 25 + { 26 + "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", 27 + "digest": "sha256:layer2", 28 + "size": 9012 29 + } 30 + ], 31 + "annotations": { 32 + "org.opencontainers.image.created": "2025-01-01T00:00:00Z" 33 + } 34 + }` 35 + 36 + manifestWithSubject := `{ 37 + "schemaVersion": 2, 38 + "mediaType": "application/vnd.oci.image.manifest.v1+json", 39 + "config": { 40 + "mediaType": "application/vnd.oci.image.config.v1+json", 41 + "digest": "sha256:config123", 42 + "size": 1234 43 + }, 44 + "layers": [], 45 + "subject": { 46 + "mediaType": "application/vnd.oci.image.manifest.v1+json", 47 + "digest": "sha256:subject123", 48 + "size": 4321 49 + } 50 + }` 51 + 52 + tests := []struct { 53 + name string 54 + repository string 55 + digest string 56 + ociManifest string 57 + wantErr bool 58 + checkFunc func(*testing.T, *ManifestRecord) 59 + }{ 60 + { 61 + name: "valid OCI manifest", 62 + repository: "myapp", 63 + digest: "sha256:abc123", 64 + ociManifest: validOCIManifest, 65 + wantErr: false, 66 + checkFunc: func(t *testing.T, record *ManifestRecord) { 67 + if record.Type != ManifestCollection { 68 + t.Errorf("Type = %v, want %v", record.Type, ManifestCollection) 69 + } 70 + if record.Repository != "myapp" { 71 + t.Errorf("Repository = %v, want myapp", record.Repository) 72 + } 73 + if record.Digest != "sha256:abc123" { 74 + t.Errorf("Digest = %v, want sha256:abc123", record.Digest) 75 + } 76 + if record.SchemaVersion != 2 { 77 + t.Errorf("SchemaVersion = %v, want 2", record.SchemaVersion) 78 + } 79 + if record.MediaType != "application/vnd.oci.image.manifest.v1+json" { 80 + t.Errorf("MediaType = %v, want application/vnd.oci.image.manifest.v1+json", record.MediaType) 81 + } 82 + if record.Config.Digest != "sha256:config123" { 83 + t.Errorf("Config.Digest = %v, want sha256:config123", record.Config.Digest) 84 + } 85 + if record.Config.Size != 1234 { 86 + t.Errorf("Config.Size = %v, want 1234", record.Config.Size) 87 + } 88 + if len(record.Layers) != 2 { 89 + t.Fatalf("len(Layers) = %v, want 2", len(record.Layers)) 90 + } 91 + if record.Layers[0].Digest != "sha256:layer1" { 92 + t.Errorf("Layers[0].Digest = %v, want sha256:layer1", record.Layers[0].Digest) 93 + } 94 + if record.Layers[1].Digest != "sha256:layer2" { 95 + t.Errorf("Layers[1].Digest = %v, want sha256:layer2", record.Layers[1].Digest) 96 + } 97 + if record.Annotations["org.opencontainers.image.created"] != "2025-01-01T00:00:00Z" { 98 + t.Errorf("Annotations missing expected key") 99 + } 100 + if record.CreatedAt.IsZero() { 101 + t.Error("CreatedAt should not be zero") 102 + } 103 + if record.Subject != nil { 104 + t.Error("Subject should be nil") 105 + } 106 + }, 107 + }, 108 + { 109 + name: "manifest with subject", 110 + repository: "myapp", 111 + digest: "sha256:abc123", 112 + ociManifest: manifestWithSubject, 113 + wantErr: false, 114 + checkFunc: func(t *testing.T, record *ManifestRecord) { 115 + if record.Subject == nil { 116 + t.Fatal("Subject should not be nil") 117 + } 118 + if record.Subject.Digest != "sha256:subject123" { 119 + t.Errorf("Subject.Digest = %v, want sha256:subject123", record.Subject.Digest) 120 + } 121 + if record.Subject.Size != 4321 { 122 + t.Errorf("Subject.Size = %v, want 4321", record.Subject.Size) 123 + } 124 + }, 125 + }, 126 + { 127 + name: "invalid JSON", 128 + repository: "myapp", 129 + digest: "sha256:abc123", 130 + ociManifest: "not valid json", 131 + wantErr: true, 132 + }, 133 + { 134 + name: "invalid config JSON", 135 + repository: "myapp", 136 + digest: "sha256:abc123", 137 + ociManifest: `{"schemaVersion": 2, "mediaType": "test", "config": "not-an-object", "layers": []}`, 138 + wantErr: true, 139 + }, 140 + } 141 + 142 + for _, tt := range tests { 143 + t.Run(tt.name, func(t *testing.T) { 144 + got, err := NewManifestRecord(tt.repository, tt.digest, []byte(tt.ociManifest)) 145 + if (err != nil) != tt.wantErr { 146 + t.Errorf("NewManifestRecord() error = %v, wantErr %v", err, tt.wantErr) 147 + return 148 + } 149 + 150 + if !tt.wantErr && tt.checkFunc != nil { 151 + tt.checkFunc(t, got) 152 + } 153 + }) 154 + } 155 + } 156 + 157 + func TestNewTagRecord(t *testing.T) { 158 + before := time.Now() 159 + record := NewTagRecord("myapp", "latest", "sha256:abc123") 160 + after := time.Now() 161 + 162 + if record.Type != TagCollection { 163 + t.Errorf("Type = %v, want %v", record.Type, TagCollection) 164 + } 165 + 166 + if record.Repository != "myapp" { 167 + t.Errorf("Repository = %v, want myapp", record.Repository) 168 + } 169 + 170 + if record.Tag != "latest" { 171 + t.Errorf("Tag = %v, want latest", record.Tag) 172 + } 173 + 174 + if record.ManifestDigest != "sha256:abc123" { 175 + t.Errorf("ManifestDigest = %v, want sha256:abc123", record.ManifestDigest) 176 + } 177 + 178 + if record.UpdatedAt.Before(before) || record.UpdatedAt.After(after) { 179 + t.Errorf("UpdatedAt = %v, want between %v and %v", record.UpdatedAt, before, after) 180 + } 181 + } 182 + 183 + func TestNewHoldRecord(t *testing.T) { 184 + tests := []struct { 185 + name string 186 + endpoint string 187 + owner string 188 + public bool 189 + }{ 190 + { 191 + name: "public hold", 192 + endpoint: "https://hold1.example.com", 193 + owner: "did:plc:alice123", 194 + public: true, 195 + }, 196 + { 197 + name: "private hold", 198 + endpoint: "https://hold2.example.com", 199 + owner: "did:plc:bob456", 200 + public: false, 201 + }, 202 + } 203 + 204 + for _, tt := range tests { 205 + t.Run(tt.name, func(t *testing.T) { 206 + before := time.Now() 207 + record := NewHoldRecord(tt.endpoint, tt.owner, tt.public) 208 + after := time.Now() 209 + 210 + if record.Type != HoldCollection { 211 + t.Errorf("Type = %v, want %v", record.Type, HoldCollection) 212 + } 213 + 214 + if record.Endpoint != tt.endpoint { 215 + t.Errorf("Endpoint = %v, want %v", record.Endpoint, tt.endpoint) 216 + } 217 + 218 + if record.Owner != tt.owner { 219 + t.Errorf("Owner = %v, want %v", record.Owner, tt.owner) 220 + } 221 + 222 + if record.Public != tt.public { 223 + t.Errorf("Public = %v, want %v", record.Public, tt.public) 224 + } 225 + 226 + if record.CreatedAt.Before(before) || record.CreatedAt.After(after) { 227 + t.Errorf("CreatedAt = %v, want between %v and %v", record.CreatedAt, before, after) 228 + } 229 + }) 230 + } 231 + } 232 + 233 + func TestNewSailorProfileRecord(t *testing.T) { 234 + tests := []struct { 235 + name string 236 + defaultHold string 237 + }{ 238 + { 239 + name: "with default hold DID", 240 + defaultHold: "did:web:hold01.atcr.io", 241 + }, 242 + { 243 + name: "with default hold URL", 244 + defaultHold: "https://hold01.atcr.io", 245 + }, 246 + { 247 + name: "empty default hold", 248 + defaultHold: "", 249 + }, 250 + } 251 + 252 + for _, tt := range tests { 253 + t.Run(tt.name, func(t *testing.T) { 254 + before := time.Now() 255 + record := NewSailorProfileRecord(tt.defaultHold) 256 + after := time.Now() 257 + 258 + if record.Type != SailorProfileCollection { 259 + t.Errorf("Type = %v, want %v", record.Type, SailorProfileCollection) 260 + } 261 + 262 + if record.DefaultHold != tt.defaultHold { 263 + t.Errorf("DefaultHold = %v, want %v", record.DefaultHold, tt.defaultHold) 264 + } 265 + 266 + if record.CreatedAt.Before(before) || record.CreatedAt.After(after) { 267 + t.Errorf("CreatedAt = %v, want between %v and %v", record.CreatedAt, before, after) 268 + } 269 + 270 + if record.UpdatedAt.Before(before) || record.UpdatedAt.After(after) { 271 + t.Errorf("UpdatedAt = %v, want between %v and %v", record.UpdatedAt, before, after) 272 + } 273 + 274 + // CreatedAt and UpdatedAt should be equal for new records 275 + if !record.CreatedAt.Equal(record.UpdatedAt) { 276 + t.Errorf("CreatedAt (%v) != UpdatedAt (%v)", record.CreatedAt, record.UpdatedAt) 277 + } 278 + }) 279 + } 280 + } 281 + 282 + func TestNewStarRecord(t *testing.T) { 283 + before := time.Now() 284 + record := NewStarRecord("did:plc:alice123", "myapp") 285 + after := time.Now() 286 + 287 + if record.Type != StarCollection { 288 + t.Errorf("Type = %v, want %v", record.Type, StarCollection) 289 + } 290 + 291 + if record.Subject.DID != "did:plc:alice123" { 292 + t.Errorf("Subject.DID = %v, want did:plc:alice123", record.Subject.DID) 293 + } 294 + 295 + if record.Subject.Repository != "myapp" { 296 + t.Errorf("Subject.Repository = %v, want myapp", record.Subject.Repository) 297 + } 298 + 299 + if record.CreatedAt.Before(before) || record.CreatedAt.After(after) { 300 + t.Errorf("CreatedAt = %v, want between %v and %v", record.CreatedAt, before, after) 301 + } 302 + } 303 + 304 + func TestStarRecordKey(t *testing.T) { 305 + tests := []struct { 306 + name string 307 + ownerDID string 308 + repository string 309 + wantPrefix string // Expected prefix for validation 310 + }{ 311 + { 312 + name: "simple key", 313 + ownerDID: "did:plc:alice123", 314 + repository: "myapp", 315 + }, 316 + { 317 + name: "long DID and repo", 318 + ownerDID: "did:plc:abcdefghijklmnopqrstuvwxyz123456", 319 + repository: "my-very-long-repository-name", 320 + }, 321 + { 322 + name: "special characters in repo", 323 + ownerDID: "did:plc:alice123", 324 + repository: "my-app_test.v1", 325 + }, 326 + } 327 + 328 + for _, tt := range tests { 329 + t.Run(tt.name, func(t *testing.T) { 330 + key := StarRecordKey(tt.ownerDID, tt.repository) 331 + 332 + // Key should be non-empty 333 + if key == "" { 334 + t.Error("StarRecordKey() returned empty string") 335 + } 336 + 337 + // Key should be base64 URL-encoded (no padding) 338 + if strings.Contains(key, "=") { 339 + t.Errorf("StarRecordKey() = %v, should not contain padding", key) 340 + } 341 + 342 + // Should be deterministic 343 + key2 := StarRecordKey(tt.ownerDID, tt.repository) 344 + if key != key2 { 345 + t.Errorf("StarRecordKey() not deterministic: %v != %v", key, key2) 346 + } 347 + 348 + // Should be different for different inputs 349 + differentKey := StarRecordKey(tt.ownerDID, tt.repository+"different") 350 + if key == differentKey { 351 + t.Error("StarRecordKey() should be different for different inputs") 352 + } 353 + }) 354 + } 355 + } 356 + 357 + func TestParseStarRecordKey(t *testing.T) { 358 + tests := []struct { 359 + name string 360 + ownerDID string 361 + repository string 362 + wantErr bool 363 + }{ 364 + { 365 + name: "valid key", 366 + ownerDID: "did:plc:alice123", 367 + repository: "myapp", 368 + wantErr: false, 369 + }, 370 + { 371 + name: "key with special characters", 372 + ownerDID: "did:plc:alice123", 373 + repository: "my-app_test.v1", 374 + wantErr: false, 375 + }, 376 + { 377 + name: "long values", 378 + ownerDID: "did:plc:abcdefghijklmnopqrstuvwxyz123456", 379 + repository: "my-very-long-repository-name", 380 + wantErr: false, 381 + }, 382 + } 383 + 384 + for _, tt := range tests { 385 + t.Run(tt.name, func(t *testing.T) { 386 + // Generate key 387 + key := StarRecordKey(tt.ownerDID, tt.repository) 388 + 389 + // Parse it back 390 + gotDID, gotRepo, err := ParseStarRecordKey(key) 391 + if (err != nil) != tt.wantErr { 392 + t.Errorf("ParseStarRecordKey() error = %v, wantErr %v", err, tt.wantErr) 393 + return 394 + } 395 + 396 + if !tt.wantErr { 397 + if gotDID != tt.ownerDID { 398 + t.Errorf("ParseStarRecordKey() DID = %v, want %v", gotDID, tt.ownerDID) 399 + } 400 + if gotRepo != tt.repository { 401 + t.Errorf("ParseStarRecordKey() repository = %v, want %v", gotRepo, tt.repository) 402 + } 403 + } 404 + }) 405 + } 406 + } 407 + 408 + func TestParseStarRecordKey_Invalid(t *testing.T) { 409 + tests := []struct { 410 + name string 411 + rkey string 412 + }{ 413 + { 414 + name: "invalid base64", 415 + rkey: "not!!!valid!!!base64", 416 + }, 417 + { 418 + name: "no separator - base64 encoded text without slash", 419 + rkey: "bm9zZXBhcmF0b3I", // base64 of "noseparator" (no "/" in the decoded value) 420 + }, 421 + { 422 + name: "empty string", 423 + rkey: "", 424 + }, 425 + } 426 + 427 + for _, tt := range tests { 428 + t.Run(tt.name, func(t *testing.T) { 429 + _, _, err := ParseStarRecordKey(tt.rkey) 430 + if err == nil { 431 + t.Error("ParseStarRecordKey() expected error for invalid input") 432 + } 433 + }) 434 + } 435 + } 436 + 437 + func TestResolveHoldDIDFromURL(t *testing.T) { 438 + tests := []struct { 439 + name string 440 + holdURL string 441 + want string 442 + }{ 443 + { 444 + name: "https URL", 445 + holdURL: "https://hold01.atcr.io", 446 + want: "did:web:hold01.atcr.io", 447 + }, 448 + { 449 + name: "http URL", 450 + holdURL: "http://hold01.atcr.io", 451 + want: "did:web:hold01.atcr.io", 452 + }, 453 + { 454 + name: "URL with trailing slash", 455 + holdURL: "https://hold01.atcr.io/", 456 + want: "did:web:hold01.atcr.io", 457 + }, 458 + { 459 + name: "URL with path", 460 + holdURL: "https://hold01.atcr.io/some/path", 461 + want: "did:web:hold01.atcr.io", 462 + }, 463 + { 464 + name: "URL with port", 465 + holdURL: "https://hold01.atcr.io:8080", 466 + want: "did:web:hold01.atcr.io:8080", 467 + }, 468 + { 469 + name: "already a did:web", 470 + holdURL: "did:web:hold01.atcr.io", 471 + want: "did:web:hold01.atcr.io", 472 + }, 473 + { 474 + name: "already a did:plc", 475 + holdURL: "did:plc:abc123", 476 + want: "did:plc:abc123", 477 + }, 478 + { 479 + name: "empty string", 480 + holdURL: "", 481 + want: "", 482 + }, 483 + { 484 + name: "localhost", 485 + holdURL: "http://localhost:8080", 486 + want: "did:web:localhost:8080", 487 + }, 488 + { 489 + name: "IP address", 490 + holdURL: "http://192.168.1.1:8080", 491 + want: "did:web:192.168.1.1:8080", 492 + }, 493 + } 494 + 495 + for _, tt := range tests { 496 + t.Run(tt.name, func(t *testing.T) { 497 + got := ResolveHoldDIDFromURL(tt.holdURL) 498 + if got != tt.want { 499 + t.Errorf("ResolveHoldDIDFromURL() = %v, want %v", got, tt.want) 500 + } 501 + }) 502 + } 503 + } 504 + 505 + func TestIsDID(t *testing.T) { 506 + tests := []struct { 507 + name string 508 + s string 509 + want bool 510 + }{ 511 + { 512 + name: "valid did:web", 513 + s: "did:web:example.com", 514 + want: true, 515 + }, 516 + { 517 + name: "valid did:plc", 518 + s: "did:plc:abc123", 519 + want: true, 520 + }, 521 + { 522 + name: "valid did:key", 523 + s: "did:key:z6Mkfriq", 524 + want: true, 525 + }, 526 + { 527 + name: "not a DID - URL", 528 + s: "https://example.com", 529 + want: false, 530 + }, 531 + { 532 + name: "not a DID - short string", 533 + s: "did", 534 + want: false, 535 + }, 536 + { 537 + name: "not a DID - empty", 538 + s: "", 539 + want: false, 540 + }, 541 + { 542 + name: "not a DID - almost", 543 + s: "did:", 544 + want: false, 545 + }, 546 + { 547 + name: "not a DID - plain text", 548 + s: "hello world", 549 + want: false, 550 + }, 551 + } 552 + 553 + for _, tt := range tests { 554 + t.Run(tt.name, func(t *testing.T) { 555 + got := isDID(tt.s) 556 + if got != tt.want { 557 + t.Errorf("isDID() = %v, want %v", got, tt.want) 558 + } 559 + }) 560 + } 561 + } 562 + 563 + func TestManifestRecord_JSONSerialization(t *testing.T) { 564 + // Create a manifest record 565 + ociManifest := `{ 566 + "schemaVersion": 2, 567 + "mediaType": "application/vnd.oci.image.manifest.v1+json", 568 + "config": { 569 + "mediaType": "application/vnd.oci.image.config.v1+json", 570 + "digest": "sha256:config123", 571 + "size": 1234 572 + }, 573 + "layers": [ 574 + { 575 + "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", 576 + "digest": "sha256:layer1", 577 + "size": 5678 578 + } 579 + ] 580 + }` 581 + 582 + record, err := NewManifestRecord("myapp", "sha256:abc123", []byte(ociManifest)) 583 + if err != nil { 584 + t.Fatalf("NewManifestRecord() error = %v", err) 585 + } 586 + 587 + // Add hold DID 588 + record.HoldDID = "did:web:hold01.atcr.io" 589 + 590 + // Serialize to JSON 591 + jsonData, err := json.Marshal(record) 592 + if err != nil { 593 + t.Fatalf("json.Marshal() error = %v", err) 594 + } 595 + 596 + // Deserialize from JSON 597 + var decoded ManifestRecord 598 + if err := json.Unmarshal(jsonData, &decoded); err != nil { 599 + t.Fatalf("json.Unmarshal() error = %v", err) 600 + } 601 + 602 + // Verify fields 603 + if decoded.Type != record.Type { 604 + t.Errorf("Type = %v, want %v", decoded.Type, record.Type) 605 + } 606 + if decoded.Repository != record.Repository { 607 + t.Errorf("Repository = %v, want %v", decoded.Repository, record.Repository) 608 + } 609 + if decoded.Digest != record.Digest { 610 + t.Errorf("Digest = %v, want %v", decoded.Digest, record.Digest) 611 + } 612 + if decoded.HoldDID != record.HoldDID { 613 + t.Errorf("HoldDID = %v, want %v", decoded.HoldDID, record.HoldDID) 614 + } 615 + if decoded.Config.Digest != record.Config.Digest { 616 + t.Errorf("Config.Digest = %v, want %v", decoded.Config.Digest, record.Config.Digest) 617 + } 618 + if len(decoded.Layers) != len(record.Layers) { 619 + t.Errorf("len(Layers) = %v, want %v", len(decoded.Layers), len(record.Layers)) 620 + } 621 + } 622 + 623 + func TestBlobReference_JSONSerialization(t *testing.T) { 624 + blob := BlobReference{ 625 + MediaType: "application/vnd.oci.image.layer.v1.tar+gzip", 626 + Digest: "sha256:abc123", 627 + Size: 12345, 628 + URLs: []string{"https://s3.example.com/blob"}, 629 + Annotations: map[string]string{ 630 + "key": "value", 631 + }, 632 + } 633 + 634 + // Serialize 635 + jsonData, err := json.Marshal(blob) 636 + if err != nil { 637 + t.Fatalf("json.Marshal() error = %v", err) 638 + } 639 + 640 + // Deserialize 641 + var decoded BlobReference 642 + if err := json.Unmarshal(jsonData, &decoded); err != nil { 643 + t.Fatalf("json.Unmarshal() error = %v", err) 644 + } 645 + 646 + // Verify 647 + if decoded.MediaType != blob.MediaType { 648 + t.Errorf("MediaType = %v, want %v", decoded.MediaType, blob.MediaType) 649 + } 650 + if decoded.Digest != blob.Digest { 651 + t.Errorf("Digest = %v, want %v", decoded.Digest, blob.Digest) 652 + } 653 + if decoded.Size != blob.Size { 654 + t.Errorf("Size = %v, want %v", decoded.Size, blob.Size) 655 + } 656 + } 657 + 658 + func TestStarSubject_JSONSerialization(t *testing.T) { 659 + subject := StarSubject{ 660 + DID: "did:plc:alice123", 661 + Repository: "myapp", 662 + } 663 + 664 + // Serialize 665 + jsonData, err := json.Marshal(subject) 666 + if err != nil { 667 + t.Fatalf("json.Marshal() error = %v", err) 668 + } 669 + 670 + // Deserialize 671 + var decoded StarSubject 672 + if err := json.Unmarshal(jsonData, &decoded); err != nil { 673 + t.Fatalf("json.Unmarshal() error = %v", err) 674 + } 675 + 676 + // Verify 677 + if decoded.DID != subject.DID { 678 + t.Errorf("DID = %v, want %v", decoded.DID, subject.DID) 679 + } 680 + if decoded.Repository != subject.Repository { 681 + t.Errorf("Repository = %v, want %v", decoded.Repository, subject.Repository) 682 + } 683 + }
+8 -8
pkg/atproto/manifest_store.go
··· 21 21 // ManifestStore implements distribution.ManifestService 22 22 // It stores manifests in ATProto as records 23 23 type ManifestStore struct { 24 - client *Client 25 - repository string 26 - holdEndpoint string // Hold service endpoint URL (for legacy, to be deprecated) 27 - holdDID string // Hold service DID (primary reference) 28 - did string // User's DID for cache key 29 - lastFetchedHoldDID string // Hold DID from most recently fetched manifest (for pull) 30 - blobStore distribution.BlobStore // Blob store for fetching config during push 31 - database DatabaseMetrics // Database for metrics tracking 24 + client *Client 25 + repository string 26 + holdEndpoint string // Hold service endpoint URL (for legacy, to be deprecated) 27 + holdDID string // Hold service DID (primary reference) 28 + did string // User's DID for cache key 29 + lastFetchedHoldDID string // Hold DID from most recently fetched manifest (for pull) 30 + blobStore distribution.BlobStore // Blob store for fetching config during push 31 + database DatabaseMetrics // Database for metrics tracking 32 32 } 33 33 34 34 // NewManifestStore creates a new ATProto-backed manifest store
+1 -11
pkg/atproto/profile.go
··· 55 55 record, err := client.GetRecord(ctx, SailorProfileCollection, ProfileRKey) 56 56 if err != nil { 57 57 // Check if it's a 404 (profile doesn't exist) 58 - if isNotFoundError(err) { 58 + if errors.Is(err, ErrRecordNotFound) { 59 59 return nil, nil 60 60 } 61 61 return nil, fmt.Errorf("failed to get profile: %w", err) ··· 104 104 return &profile, nil 105 105 } 106 106 107 - // isDID checks if a string is a DID (starts with "did:") 108 - func isDID(s string) bool { 109 - return len(s) > 4 && s[:4] == "did:" 110 - } 111 - 112 107 // UpdateProfile updates the user's profile 113 108 // Normalizes defaultHold to DID format before saving 114 109 func UpdateProfile(ctx context.Context, client *Client, profile *SailorProfileRecord) error { ··· 125 120 } 126 121 return nil 127 122 } 128 - 129 - // isNotFoundError checks if an error is a record not found error 130 - func isNotFoundError(err error) bool { 131 - return errors.Is(err, ErrRecordNotFound) 132 - }
+2 -2
pkg/hold/blobstore_adapter.go
··· 105 105 URL: fmt.Sprintf("%s/xrpc/com.atproto.repo.uploadBlob", b.service.config.Server.PublicURL), 106 106 Method: "PUT", 107 107 Headers: map[string]string{ 108 - "X-Upload-Id": uploadID, 109 - "X-Part-Number": fmt.Sprintf("%d", partNumber), 108 + "X-Upload-Id": uploadID, 109 + "X-Part-Number": fmt.Sprintf("%d", partNumber), 110 110 }, 111 111 }, nil 112 112 }
-1
pkg/hold/multipart.go
··· 333 333 log.Printf("Aborted buffered multipart: uploadID=%s", session.UploadID) 334 334 return nil 335 335 } 336 -