package db_test import ( "context" "database/sql" "os" "path/filepath" "runtime" "strings" "testing" "update_server/internal/db" ) func TestMigrateAppliesCoreSchema(t *testing.T) { t.Parallel() ctx := context.Background() sqlitePath := filepath.Join(t.TempDir(), "update-server.sqlite") database, err := db.Open(ctx, sqlitePath) if err != nil { t.Fatalf("open database: %v", err) } defer database.Close() migrationsDir := projectMigrationsDir(t) if err := db.Migrate(ctx, database, migrationsDir); err != nil { t.Fatalf("apply migrations: %v", err) } if err := db.Migrate(ctx, database, migrationsDir); err != nil { t.Fatalf("reapply migrations: %v", err) } expectedTables := []string{ "schema_migrations", "users", "projects", "tags", "project_tags", "releases", "api_keys", "api_key_project_access", "api_key_tag_access", "sessions", "audit_logs", } for _, tableName := range expectedTables { if !tableExists(t, database, tableName) { t.Fatalf("expected table %q to exist", tableName) } } if _, err := database.ExecContext( ctx, `INSERT INTO users (email, password_hash, role) VALUES (?, ?, ?)`, "admin@example.com", "hashed-password", "admin", ); err != nil { t.Fatalf("insert user: %v", err) } if _, err := database.ExecContext( ctx, `INSERT INTO projects (name, slug, description) VALUES (?, ?, ?)`, "Desktop App", "desktop-app", "Primary desktop client", ); err != nil { t.Fatalf("insert project: %v", err) } if _, err := database.ExecContext( ctx, `INSERT INTO tags (name, slug, description) VALUES (?, ?, ?)`, "Windows", "windows", "Windows releases", ); err != nil { t.Fatalf("insert tag: %v", err) } var projectID int64 if err := database.QueryRowContext(ctx, `SELECT id FROM projects WHERE slug = ?`, "desktop-app").Scan(&projectID); err != nil { t.Fatalf("load project id: %v", err) } var tagID int64 if err := database.QueryRowContext(ctx, `SELECT id FROM tags WHERE slug = ?`, "windows").Scan(&tagID); err != nil { t.Fatalf("load tag id: %v", err) } var userID int64 if err := database.QueryRowContext(ctx, `SELECT id FROM users WHERE email = ?`, "admin@example.com").Scan(&userID); err != nil { t.Fatalf("load user id: %v", err) } if _, err := database.ExecContext( ctx, `INSERT INTO project_tags (project_id, tag_id) VALUES (?, ?)`, projectID, tagID, ); err != nil { t.Fatalf("insert project tag: %v", err) } if _, err := database.ExecContext( ctx, `INSERT INTO releases (project_id, version, build, filename, storage_path, checksum_sha256, size_bytes, content_type, release_notes, uploaded_by_user_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, projectID, "1.0.0", "", "desktop-app-1.0.0.zip", "artifacts/desktop-app/1.0.0/desktop-app-1.0.0.zip", "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", 1024, "application/zip", "Initial release", userID, ); err != nil { t.Fatalf("insert release: %v", err) } if _, err := database.ExecContext( ctx, `INSERT INTO api_keys (name, key_prefix, key_hash, description, scope_mode, can_download, created_by_user_id) VALUES (?, ?, ?, ?, ?, ?, ?)`, "Desktop Clients", "updsrv_project", "hash-project", "Project-scoped desktop client access", "project_allow_list", 1, userID, ); err != nil { t.Fatalf("insert project api key: %v", err) } var projectAPIKeyID int64 if err := database.QueryRowContext(ctx, `SELECT id FROM api_keys WHERE key_prefix = ?`, "updsrv_project").Scan(&projectAPIKeyID); err != nil { t.Fatalf("load project api key id: %v", err) } if _, err := database.ExecContext( ctx, `INSERT INTO api_key_project_access (api_key_id, project_id) VALUES (?, ?)`, projectAPIKeyID, projectID, ); err != nil { t.Fatalf("insert api key project access: %v", err) } if _, err := database.ExecContext( ctx, `INSERT INTO api_keys (name, key_prefix, key_hash, description, scope_mode, can_download, created_by_user_id) VALUES (?, ?, ?, ?, ?, ?, ?)`, "Tagged Clients", "updsrv_tag", "hash-tag", "Tag-scoped desktop client access", "tag_allow_list", 1, userID, ); err != nil { t.Fatalf("insert tag api key: %v", err) } var tagAPIKeyID int64 if err := database.QueryRowContext(ctx, `SELECT id FROM api_keys WHERE key_prefix = ?`, "updsrv_tag").Scan(&tagAPIKeyID); err != nil { t.Fatalf("load tag api key id: %v", err) } if _, err := database.ExecContext( ctx, `INSERT INTO api_key_tag_access (api_key_id, tag_id) VALUES (?, ?)`, tagAPIKeyID, tagID, ); err != nil { t.Fatalf("insert api key tag access: %v", err) } if _, err := database.ExecContext( ctx, `INSERT INTO sessions (user_id, token_hash, expires_at, ip_address, user_agent) VALUES (?, ?, ?, ?, ?)`, userID, "session-hash", "2030-01-01T00:00:00Z", "127.0.0.1", "test-agent", ); err != nil { t.Fatalf("insert session: %v", err) } if _, err := database.ExecContext( ctx, `INSERT INTO audit_logs (actor_user_id, api_key_id, action, target_type, target_id, target_identifier, metadata_json, ip_address) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, userID, tagAPIKeyID, "api_key.created", "api_key", tagAPIKeyID, "updsrv_tag", `{"source":"test"}`, "127.0.0.1", ); err != nil { t.Fatalf("insert audit log: %v", err) } if _, err := database.ExecContext( ctx, `INSERT INTO api_key_project_access (api_key_id, project_id) VALUES (?, ?)`, tagAPIKeyID, projectID, ); err == nil { t.Fatal("expected project access insert for tag-scoped key to fail") } if _, err := database.ExecContext( ctx, `INSERT INTO api_keys (name, key_prefix, key_hash, scope_mode) VALUES (?, ?, ?, ?)`, "Broken Key", "updsrv_invalid", "hash-invalid", "invalid_scope", ); err == nil { t.Fatal("expected invalid scope_mode insert to fail") } } func projectMigrationsDir(t *testing.T) string { t.Helper() _, filename, _, ok := runtime.Caller(0) if !ok { t.Fatal("resolve caller path") } return filepath.Join(filepath.Dir(filename), "..", "..", "migrations") } func tableExists(t *testing.T, database *sql.DB, tableName string) bool { t.Helper() var exists int query := `SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?)` if err := database.QueryRowContext(context.Background(), query, tableName).Scan(&exists); err != nil { t.Fatalf("check table %s: %v", tableName, err) } return exists == 1 } func TestMigrateRejectsEditedAppliedMigrations(t *testing.T) { t.Parallel() ctx := context.Background() tempDir := t.TempDir() sqlitePath := filepath.Join(tempDir, "update-server.sqlite") database, err := db.Open(ctx, sqlitePath) if err != nil { t.Fatalf("open database: %v", err) } defer database.Close() migrationsDir := filepath.Join(tempDir, "migrations") if err := os.MkdirAll(migrationsDir, 0o755); err != nil { t.Fatalf("create migrations dir: %v", err) } firstMigrationPath := filepath.Join(migrationsDir, "0001_test.sql") if err := os.WriteFile(firstMigrationPath, []byte(`CREATE TABLE sample (id INTEGER PRIMARY KEY);`), 0o644); err != nil { t.Fatalf("write migration: %v", err) } if err := db.Migrate(ctx, database, migrationsDir); err != nil { t.Fatalf("apply migration: %v", err) } if err := os.WriteFile(firstMigrationPath, []byte(`CREATE TABLE sample (id INTEGER PRIMARY KEY, name TEXT);`), 0o644); err != nil { t.Fatalf("rewrite migration: %v", err) } err = db.Migrate(ctx, database, migrationsDir) if err == nil { t.Fatal("expected checksum mismatch error") } expectedMessage := "checksum mismatch" if !strings.Contains(err.Error(), expectedMessage) { t.Fatalf("expected error containing %q, got %v", expectedMessage, err) } }