mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-20 09:14:16 +00:00
Completes the swap 0033 set up. layers and manifest_references move onto manifest_key and manifests.id is gone, which removes the last node-allocated identifier in the AppView schema. Statement order in 0034 is load-bearing. With foreign keys on, DROP TABLE performs an implicit DELETE FROM, so dropping manifests while layers still holds an ON DELETE CASCADE reference deletes every layer row. Migration 0009 did exactly that; it went unnoticed because the Jetstream backfill rebuilds layers from PDS records, so the damage healed itself. PRAGMA foreign_keys is no help: it is a no-op inside a transaction and migrations run in one. So the new children are built pointing at manifests_new, the old children are dropped first, and only then is the old manifests table dropped, by which point nothing references it. Verified both behaviors before relying on them. manifest_key is declared NOT NULL as well as PRIMARY KEY, because in SQLite a PRIMARY KEY column still accepts NULL unless it is INTEGER PRIMARY KEY. That constraint immediately caught four test helpers inserting manifests without one. Five queries used MAX(id) as "the newest manifest in this repo", which I had previously reported as absent after grepping only for ORDER BY. A derived key has no ordering, so recency now comes from created_at with manifest_key as a deterministic tiebreak. This is a real behavior change, and a fix: the two disagree whenever a manifest is indexed out of order, which the backfill does routinely, and created_at is the push time these queries always wanted. Both directions are tested, including that ties resolve the same way every run. InsertManifest and BatchInsertManifests no longer read anything back. The key is derived from (did, repository, digest), so the writer knows it before the statement runs: the select-back, its per-DID IN list, and the "manifest missing id after batch insert" branch all go away, along with the UNIQUE-conflict fallback that existed only to recover a rowid. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
332 lines
8.8 KiB
Go
332 lines
8.8 KiB
Go
package handlers
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
|
|
"atcr.io/pkg/appview/db"
|
|
"atcr.io/pkg/appview/middleware"
|
|
_ "github.com/tursodatabase/go-libsql"
|
|
)
|
|
|
|
func TestDeleteAccountHandler_Unauthorized(t *testing.T) {
|
|
database := setupTestDB(t)
|
|
defer database.Close()
|
|
|
|
handler := &DeleteAccountHandler{
|
|
BaseUIHandler: BaseUIHandler{
|
|
DB: database,
|
|
OAuthStore: nil,
|
|
Refresher: nil,
|
|
},
|
|
}
|
|
|
|
reqBody := DeleteAccountRequest{
|
|
DeletePDSRecords: false,
|
|
Confirmation: "DELETE test.bsky.social",
|
|
}
|
|
body, _ := json.Marshal(reqBody)
|
|
req := httptest.NewRequest("DELETE", "/api/account", bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
rr := httptest.NewRecorder()
|
|
handler.ServeHTTP(rr, req)
|
|
|
|
if rr.Code != http.StatusUnauthorized {
|
|
t.Errorf("Expected status %d, got %d", http.StatusUnauthorized, rr.Code)
|
|
}
|
|
}
|
|
|
|
func TestDeleteAccountHandler_MissingConfirmation(t *testing.T) {
|
|
database := setupTestDB(t)
|
|
defer database.Close()
|
|
|
|
// Create test user
|
|
testUser := &db.User{
|
|
DID: "did:plc:test123",
|
|
Handle: "test.bsky.social",
|
|
PDSEndpoint: "https://bsky.social",
|
|
LastSeen: time.Now(),
|
|
}
|
|
if err := db.UpsertUser(database, testUser); err != nil {
|
|
t.Fatalf("Failed to create user: %v", err)
|
|
}
|
|
|
|
handler := &DeleteAccountHandler{
|
|
BaseUIHandler: BaseUIHandler{
|
|
DB: database,
|
|
OAuthStore: nil,
|
|
Refresher: nil,
|
|
},
|
|
}
|
|
|
|
// Request without confirmation
|
|
reqBody := DeleteAccountRequest{
|
|
DeletePDSRecords: false,
|
|
Confirmation: "",
|
|
}
|
|
body, _ := json.Marshal(reqBody)
|
|
req := httptest.NewRequest("DELETE", "/api/account", bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req = middleware.WithUser(req, testUser)
|
|
|
|
rr := httptest.NewRecorder()
|
|
handler.ServeHTTP(rr, req)
|
|
|
|
if rr.Code != http.StatusBadRequest {
|
|
t.Errorf("Expected status %d, got %d", http.StatusBadRequest, rr.Code)
|
|
}
|
|
}
|
|
|
|
func TestDeleteAccountHandler_WrongConfirmation(t *testing.T) {
|
|
database := setupTestDB(t)
|
|
defer database.Close()
|
|
|
|
testUser := &db.User{
|
|
DID: "did:plc:test123",
|
|
Handle: "test.bsky.social",
|
|
PDSEndpoint: "https://bsky.social",
|
|
LastSeen: time.Now(),
|
|
}
|
|
if err := db.UpsertUser(database, testUser); err != nil {
|
|
t.Fatalf("Failed to create user: %v", err)
|
|
}
|
|
|
|
handler := &DeleteAccountHandler{
|
|
BaseUIHandler: BaseUIHandler{
|
|
DB: database,
|
|
OAuthStore: nil,
|
|
Refresher: nil,
|
|
},
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
confirmation string
|
|
}{
|
|
{"just DELETE", "DELETE"},
|
|
{"wrong handle", "DELETE wrong.handle"},
|
|
{"lowercase", "delete test.bsky.social"},
|
|
{"extra spaces", "DELETE test.bsky.social"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
reqBody := DeleteAccountRequest{
|
|
DeletePDSRecords: false,
|
|
Confirmation: tt.confirmation,
|
|
}
|
|
body, _ := json.Marshal(reqBody)
|
|
req := httptest.NewRequest("DELETE", "/api/account", bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req = middleware.WithUser(req, testUser)
|
|
|
|
rr := httptest.NewRecorder()
|
|
handler.ServeHTTP(rr, req)
|
|
|
|
if rr.Code != http.StatusBadRequest {
|
|
t.Errorf("Expected status %d for confirmation %q, got %d", http.StatusBadRequest, tt.confirmation, rr.Code)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDeleteAccountHandler_SuccessfulDeletion(t *testing.T) {
|
|
database := setupTestDB(t)
|
|
defer database.Close()
|
|
|
|
// Create test user with some data
|
|
testUser := &db.User{
|
|
DID: "did:plc:test123",
|
|
Handle: "test.bsky.social",
|
|
PDSEndpoint: "https://bsky.social",
|
|
LastSeen: time.Now(),
|
|
}
|
|
if err := db.UpsertUser(database, testUser); err != nil {
|
|
t.Fatalf("Failed to create user: %v", err)
|
|
}
|
|
|
|
// Create some manifests for the user
|
|
_, err := database.Exec(`
|
|
INSERT INTO manifests (manifest_key, did, repository, digest, hold_endpoint, schema_version, media_type, created_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
`, db.ManifestKey(testUser.DID, "myapp", "sha256:abc123"),
|
|
testUser.DID, "myapp", "sha256:abc123", "did:web:hold.example.com", 2,
|
|
"application/vnd.oci.image.manifest.v1+json", time.Now())
|
|
if err != nil {
|
|
t.Fatalf("Failed to create manifest: %v", err)
|
|
}
|
|
|
|
// Create OAuth store for testing
|
|
oauthStore := db.NewOAuthStore(database)
|
|
|
|
handler := &DeleteAccountHandler{
|
|
BaseUIHandler: BaseUIHandler{
|
|
DB: database,
|
|
OAuthStore: oauthStore,
|
|
Refresher: nil, // No remote operations in this test
|
|
},
|
|
}
|
|
|
|
reqBody := DeleteAccountRequest{
|
|
DeletePDSRecords: false,
|
|
Confirmation: "DELETE test.bsky.social",
|
|
}
|
|
body, _ := json.Marshal(reqBody)
|
|
req := httptest.NewRequest("DELETE", "/api/account", bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req = middleware.WithUser(req, testUser)
|
|
|
|
rr := httptest.NewRecorder()
|
|
handler.ServeHTTP(rr, req)
|
|
|
|
if rr.Code != http.StatusOK {
|
|
t.Errorf("Expected status %d, got %d. Body: %s", http.StatusOK, rr.Code, rr.Body.String())
|
|
}
|
|
|
|
var response DeleteAccountResponse
|
|
if err := json.NewDecoder(rr.Body).Decode(&response); err != nil {
|
|
t.Fatalf("Failed to decode response: %v", err)
|
|
}
|
|
|
|
if !response.Success {
|
|
t.Error("Expected success=true")
|
|
}
|
|
if !response.AppViewDeleted {
|
|
t.Error("Expected appview_deleted=true")
|
|
}
|
|
|
|
// Verify user was actually deleted
|
|
var count int
|
|
err = database.QueryRow("SELECT COUNT(*) FROM users WHERE did = ?", testUser.DID).Scan(&count)
|
|
if err != nil {
|
|
t.Fatalf("Failed to query user: %v", err)
|
|
}
|
|
if count != 0 {
|
|
t.Error("Expected user to be deleted from database")
|
|
}
|
|
|
|
// Verify manifests were cascade deleted
|
|
err = database.QueryRow("SELECT COUNT(*) FROM manifests WHERE did = ?", testUser.DID).Scan(&count)
|
|
if err != nil {
|
|
t.Fatalf("Failed to query manifests: %v", err)
|
|
}
|
|
if count != 0 {
|
|
t.Error("Expected manifests to be cascade deleted")
|
|
}
|
|
}
|
|
|
|
func TestDeleteAccountHandler_InvalidJSON(t *testing.T) {
|
|
database := setupTestDB(t)
|
|
defer database.Close()
|
|
|
|
testUser := &db.User{
|
|
DID: "did:plc:test123",
|
|
Handle: "test.bsky.social",
|
|
PDSEndpoint: "https://bsky.social",
|
|
LastSeen: time.Now(),
|
|
}
|
|
if err := db.UpsertUser(database, testUser); err != nil {
|
|
t.Fatalf("Failed to create user: %v", err)
|
|
}
|
|
|
|
handler := &DeleteAccountHandler{
|
|
BaseUIHandler: BaseUIHandler{
|
|
DB: database,
|
|
OAuthStore: nil,
|
|
Refresher: nil,
|
|
},
|
|
}
|
|
|
|
req := httptest.NewRequest("DELETE", "/api/account", bytes.NewReader([]byte("not json")))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req = middleware.WithUser(req, testUser)
|
|
|
|
rr := httptest.NewRecorder()
|
|
handler.ServeHTTP(rr, req)
|
|
|
|
if rr.Code != http.StatusBadRequest {
|
|
t.Errorf("Expected status %d, got %d", http.StatusBadRequest, rr.Code)
|
|
}
|
|
}
|
|
|
|
func TestDeleteAccountHandler_DeletesHoldMembershipData(t *testing.T) {
|
|
database := setupTestDB(t)
|
|
defer database.Close()
|
|
|
|
testUser := &db.User{
|
|
DID: "did:plc:test123",
|
|
Handle: "test.bsky.social",
|
|
PDSEndpoint: "https://bsky.social",
|
|
LastSeen: time.Now(),
|
|
}
|
|
if err := db.UpsertUser(database, testUser); err != nil {
|
|
t.Fatalf("Failed to create user: %v", err)
|
|
}
|
|
|
|
// Create hold membership data (these tables don't cascade)
|
|
_, err := database.Exec(`
|
|
INSERT INTO hold_crew_approvals (hold_did, user_did, approved_at, expires_at)
|
|
VALUES (?, ?, ?, ?)
|
|
`, "did:web:hold.example.com", testUser.DID, time.Now(), time.Now().Add(24*time.Hour))
|
|
if err != nil {
|
|
t.Fatalf("Failed to create crew approval: %v", err)
|
|
}
|
|
|
|
_, err = database.Exec(`
|
|
INSERT INTO hold_crew_members (hold_did, member_did, rkey, permissions)
|
|
VALUES (?, ?, ?, ?)
|
|
`, "did:web:hold.example.com", testUser.DID, "member1", `["blob:read","blob:write"]`)
|
|
if err != nil {
|
|
t.Fatalf("Failed to create crew member: %v", err)
|
|
}
|
|
|
|
oauthStore := db.NewOAuthStore(database)
|
|
|
|
handler := &DeleteAccountHandler{
|
|
BaseUIHandler: BaseUIHandler{
|
|
DB: database,
|
|
OAuthStore: oauthStore,
|
|
Refresher: nil,
|
|
},
|
|
}
|
|
|
|
reqBody := DeleteAccountRequest{
|
|
DeletePDSRecords: false,
|
|
Confirmation: "DELETE test.bsky.social",
|
|
}
|
|
body, _ := json.Marshal(reqBody)
|
|
req := httptest.NewRequest("DELETE", "/api/account", bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req = middleware.WithUser(req, testUser)
|
|
|
|
rr := httptest.NewRecorder()
|
|
handler.ServeHTTP(rr, req)
|
|
|
|
if rr.Code != http.StatusOK {
|
|
t.Errorf("Expected status %d, got %d", http.StatusOK, rr.Code)
|
|
}
|
|
|
|
// Verify hold membership data was deleted
|
|
var count int
|
|
err = database.QueryRow("SELECT COUNT(*) FROM hold_crew_approvals WHERE user_did = ?", testUser.DID).Scan(&count)
|
|
if err != nil {
|
|
t.Fatalf("Failed to query crew approvals: %v", err)
|
|
}
|
|
if count != 0 {
|
|
t.Error("Expected crew approvals to be deleted")
|
|
}
|
|
|
|
err = database.QueryRow("SELECT COUNT(*) FROM hold_crew_members WHERE member_did = ?", testUser.DID).Scan(&count)
|
|
if err != nil {
|
|
t.Fatalf("Failed to query crew members: %v", err)
|
|
}
|
|
if count != 0 {
|
|
t.Error("Expected crew members to be deleted")
|
|
}
|
|
}
|