| 1 | package catalog |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "database/sql" |
| 6 | "os" |
| 7 | "path/filepath" |
| 8 | "testing" |
| 9 | "time" |
| 10 | ) |
| 11 | |
| 12 | func TestDeleteRequiresDisabledAndRemovesTenantData(t *testing.T) { |
| 13 | ctx := context.Background() |
| 14 | cat, err := Open(ctx, t.TempDir()) |
| 15 | if err != nil { |
| 16 | t.Fatal(err) |
| 17 | } |
| 18 | defer cat.Close() |
| 19 | meta, err := cat.Create(ctx, CreateOptions{Name: "erase-me" , Owner: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" }) |
| 20 | if err != nil { |
| 21 | t.Fatal(err) |
| 22 | } |
| 23 | if err := cat.Delete(ctx, meta.ID); err != ErrInvalidTransition { |
| 24 | t.Fatalf("delete while creating = %v, want invalid transition" , err) |
| 25 | } |
| 26 | if err := cat.MarkReady(ctx, meta.ID); err != nil { |
| 27 | t.Fatal(err) |
| 28 | } |
| 29 | if err := cat.Disable(ctx, meta.ID); err != nil { |
| 30 | t.Fatal(err) |
| 31 | } |
| 32 | if err := cat.Delete(ctx, meta.ID); err != nil { |
| 33 | t.Fatal(err) |
| 34 | } |
| 35 | if _, err := os.Stat(meta.Paths.Root); !os.IsNotExist(err) { |
| 36 | t.Fatalf("tenant root still exists: %v" , err) |
| 37 | } |
| 38 | items, err := cat.List(ctx) |
| 39 | if err != nil { |
| 40 | t.Fatal(err) |
| 41 | } |
| 42 | for _, item := range items { |
| 43 | if item.ID == meta.ID { |
| 44 | t.Fatalf("deleted tenant remains in catalog" ) |
| 45 | } |
| 46 | } |
| 47 | } |
| 48 | |
| 49 | func TestDeleteAtomicallyRemovesHostMappings(t *testing.T) { |
| 50 | ctx := context.Background() |
| 51 | cat, err := Open(ctx, t.TempDir()) |
| 52 | if err != nil { |
| 53 | t.Fatal(err) |
| 54 | } |
| 55 | defer cat.Close() |
| 56 | meta, err := cat.Create(ctx, CreateOptions{Name: "host-delete" , Owner: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" }) |
| 57 | if err != nil { |
| 58 | t.Fatal(err) |
| 59 | } |
| 60 | if err := cat.MarkReady(ctx, meta.ID); err != nil { |
| 61 | t.Fatal(err) |
| 62 | } |
| 63 | if err := cat.Disable(ctx, meta.ID); err != nil { |
| 64 | t.Fatal(err) |
| 65 | } |
| 66 | if _, err := cat.AddHost(ctx, meta.ID, "delete.example" , "" ); err != nil { |
| 67 | t.Fatal(err) |
| 68 | } |
| 69 | if err := cat.Delete(ctx, meta.ID); err != nil { |
| 70 | t.Fatal(err) |
| 71 | } |
| 72 | if _, err := cat.ResolveHostMapping(ctx, "delete.example" ); err != ErrHostMappingNotFound { |
| 73 | t.Fatalf("host mapping after delete = %v" , err) |
| 74 | } |
| 75 | } |
| 76 | |
| 77 | func TestOpenRecoversInterruptedDeletion(t *testing.T) { |
| 78 | ctx := context.Background() |
| 79 | root := t.TempDir() |
| 80 | cat, err := Open(ctx, root) |
| 81 | if err != nil { |
| 82 | t.Fatal(err) |
| 83 | } |
| 84 | meta, err := cat.Create(ctx, CreateOptions{Name: "recover-delete" , Owner: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" }) |
| 85 | if err != nil { |
| 86 | t.Fatal(err) |
| 87 | } |
| 88 | if err := cat.MarkReady(ctx, meta.ID); err != nil { |
| 89 | t.Fatal(err) |
| 90 | } |
| 91 | if err := cat.Disable(ctx, meta.ID); err != nil { |
| 92 | t.Fatal(err) |
| 93 | } |
| 94 | staging := filepath.Join(filepath.Dir(meta.Paths.Root), ".deleting-" +meta.ID) |
| 95 | if err := cat.dbTransaction(ctx, func(tx *sql.Tx) error { |
| 96 | if _, err := tx.ExecContext(ctx, "UPDATE tenants SET status=?,updated_at=? WHERE id=?" , StatusDeleting, time.Now().UnixNano(), meta.ID); err != nil { |
| 97 | return err |
| 98 | } |
| 99 | _, err := tx.ExecContext(ctx, "INSERT INTO tenant_deletions(id,root,staging,started_at) VALUES(?,?,?,?)" , meta.ID, meta.Paths.Root, staging, time.Now().UnixNano()) |
| 100 | return err |
| 101 | }); err != nil { |
| 102 | t.Fatal(err) |
| 103 | } |
| 104 | if err := os.Rename(meta.Paths.Root, staging); err != nil { |
| 105 | t.Fatal(err) |
| 106 | } |
| 107 | if err := cat.Close(); err != nil { |
| 108 | t.Fatal(err) |
| 109 | } |
| 110 | reopened, err := Open(ctx, root) |
| 111 | if err != nil { |
| 112 | t.Fatal(err) |
| 113 | } |
| 114 | defer reopened.Close() |
| 115 | if _, err := reopened.GetByID(ctx, meta.ID); err != ErrNotFound { |
| 116 | t.Fatalf("recovered deleted tenant = %v" , err) |
| 117 | } |
| 118 | if _, err := os.Stat(staging); !os.IsNotExist(err) { |
| 119 | t.Fatalf("staging directory remains: %v" , err) |
| 120 | } |
| 121 | } |
| 122 | |
| 123 | func TestOpenMigratesLegacyTenantStatusConstraint(t *testing.T) { |
| 124 | ctx := context.Background() |
| 125 | root := t.TempDir() |
| 126 | db, err := sql.Open("sqlite" , filepath.Join(root, "catalog.db" )) |
| 127 | if err != nil { |
| 128 | t.Fatal(err) |
| 129 | } |
| 130 | _, err = db.Exec(`CREATE TABLE tenants (id TEXT PRIMARY KEY, name TEXT NOT NULL COLLATE NOCASE UNIQUE, owner TEXT NOT NULL, template TEXT NOT NULL, status TEXT NOT NULL, host TEXT COLLATE NOCASE UNIQUE, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, CHECK(status IN ('creating','ready','disabled'))); INSERT INTO tenants(id,name,owner,template,status,created_at,updated_at) VALUES('legacy','legacy','0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef','default','disabled',1,1); CREATE TABLE tenant_hosts (tenant_id TEXT NOT NULL, host TEXT PRIMARY KEY COLLATE NOCASE, site TEXT NOT NULL DEFAULT '', status TEXT NOT NULL DEFAULT 'configured', created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL);` ) |
| 131 | if err != nil { |
| 132 | t.Fatal(err) |
| 133 | } |
| 134 | if err := db.Close(); err != nil { |
| 135 | t.Fatal(err) |
| 136 | } |
| 137 | cat, err := Open(ctx, root) |
| 138 | if err != nil { |
| 139 | t.Fatal(err) |
| 140 | } |
| 141 | defer cat.Close() |
| 142 | if err := cat.beginDeletion(ctx, "legacy" , cat.tenantPaths("legacy" ).Root, filepath.Join(root, "tenants" , ".deleting-legacy" )); err != nil { |
| 143 | t.Fatalf("legacy deleting status was not migrated: %v" , err) |
| 144 | } |
| 145 | } |