| new file mode 100644 |
| index 000000000..52530de47 |
| --- /dev/null |
| +++ b/internal/console/apiinstanceimport_test.go |
| @@ -0,0 +1,465 @@ |
| +package console |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "encoding/json" |
| + "log" |
| + "net/http" |
| + "os" |
| + "strings" |
| + "sync" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/objstore" |
| + "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/seeds" |
| +) |
| + |
| +const ( |
| + importHost = "old.shop.example.com" |
| + importAddr = "127.0.0.1:8401" |
| +) |
| + |
| +var importSHA = strings.Repeat("a", 64) |
| + |
| +// newImportFixture is Plan 5's --from fixture: the app shop, a ready |
| +// one-box home region, a seeds floor, a scratch pool and a data bucket. |
| +func newImportFixture(t *testing.T) fromFixture { |
| + t.Helper() |
| + return newFromFixture(t, 0) |
| +} |
| + |
| +func importBody(host, sha string, n int64, replaces string) string { |
| + body, _ := json.Marshal(map[string]any{"host": host, "import": map[string]any{"sha256": sha, "bytes": n, "replaces": replaces}}) |
| + return string(body) |
| +} |
| + |
| +func beginImport(t *testing.T, f fromFixture, host, sha string, n int64) (int, map[string]any) { |
| + t.Helper() |
| + return f.create(t, importBody(host, sha, n, importAddr)) |
| +} |
| + |
| +// requireBegun: a 201 holding a 43-character token, and an awaiting-upload |
| +// import record that keeps only the token's hash. |
| +func (f fromFixture) requireBegun(t *testing.T, code int, out map[string]any, host string) (*provision.Record, string) { |
| + t.Helper() |
| + if code != http.StatusCreated { |
| + t.Fatalf("begin = %d %v", code, out) |
| + } |
| + token := str(t, out, "verify_token") |
| + if len(token) != 43 { |
| + t.Fatalf("verify token %q", token) |
| + } |
| + rec, err := f.record(t, host) |
| + if err != nil || rec.Seed == nil || rec.Seed.Kind != seeds.KindImport || rec.Seed.State != seeds.StateAwaitingUpload || |
| + !seeds.ValidID(rec.Seed.ID) || rec.Seed.VerifyHash != seeds.HashToken(token) || !seeds.TokenMatches(rec.Seed.VerifyHash, token) { |
| + t.Fatalf("record = %+v, %v", rec, err) |
| + } |
| + return rec, token |
| +} |
| + |
| +func TestImportBegin(t *testing.T) { |
| + f := newImportFixture(t) |
| + var logs bytes.Buffer |
| + log.SetOutput(&logs) |
| + defer log.SetOutput(os.Stderr) |
| + code, out := f.create(t, `{"host":"old.shop.example.com","message":"moving the shop","import":{"sha256":"`+importSHA+`","bytes":4096,"replaces":"127.0.0.1:8401"}}`) |
| + rec, token := f.requireBegun(t, code, out, importHost) |
| + if rec.Seed.SHA256 != importSHA || rec.Seed.Bytes != 4096 || rec.Seed.Replaces != importAddr || rec.Seed.By != "alice@keymail.dev" || |
| + rec.Seed.From != "" || rec.Seed.TXID != "" || rec.Seed.Reason != "" || rec.Seed.At.IsZero() { |
| + t.Fatalf("record seed = %+v", rec.Seed) |
| + } |
| + if rec.Environment != "" || rec.Slug != "old" || rec.Channel != "stable" || rec.Region != "" { |
| + t.Fatalf("record = %+v; want the ordinary create's fields", rec) |
| + } |
| + seed, _ := out["seed"].(map[string]any) |
| + if out["host"] != importHost || seed == nil || seed["id"] != rec.Seed.ID || seed["state"] != seeds.StateAwaitingUpload { |
| + t.Fatalf("201 body = %v", out) |
| + } |
| + if _, ok := out["private_store_bucket"]; ok { |
| + t.Fatalf("201 body names a store on an app without private storage: %v", out) |
| + } |
| + |
| + // The token is answered once, here, and kept nowhere: not in the |
| + // bucket, not in the log, not in the audit row. |
| + raw, _, err := f.fx.rel.Get(context.Background(), provision.RecordKey("bab", fromApp, importHost)) |
| + if err != nil || strings.Contains(string(raw), token) { |
| + t.Fatalf("the token itself reached the bucket (%v)", err) |
| + } |
| + if strings.Contains(logs.String(), token) { |
| + t.Fatal("the token reached the log") |
| + } |
| + events, err := f.fx.st.Events(OpsAccountID, fromApp, 50) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var rows []string |
| + for _, e := range events { |
| + if strings.Contains(e.Detail+e.Message, token) { |
| + t.Errorf("the token reached an audit row: %+v", e) |
| + } |
| + switch e.Action { |
| + case "instance-seed": |
| + rows = append(rows, e.Detail+" | "+e.Message+" | "+e.Address) |
| + case "instance-create": |
| + t.Errorf("an import also wrote an instance-create row: %+v", e) |
| + } |
| + } |
| + want := "began an import of old.shop.example.com over 127.0.0.1:8401 (aaaaaaaaaaaa, 4096 bytes) | moving the shop | alice@keymail.dev" |
| + if len(rows) != 1 || rows[0] != want { |
| + t.Fatalf("instance-seed rows = %q, want [%q]", rows, want) |
| + } |
| + |
| + // A second begin for the same host: the record holds it. |
| + code, out = beginImport(t, f, importHost, importSHA, 4096) |
| + if code != http.StatusConflict || str(t, out, "error") != "instance old.shop.example.com already exists" { |
| + t.Fatalf("second begin = %d %v, want 409", code, out) |
| + } |
| + if again, err := f.record(t, importHost); err != nil || again.Seed.ID != rec.Seed.ID || again.Seed.VerifyHash != rec.Seed.VerifyHash { |
| + t.Fatalf("the second begin changed the record: %+v, %v", again, err) |
| + } |
| +} |
| + |
| +// Each begin mints its own seed id and token. |
| +func TestImportBeginMintsFreshTokens(t *testing.T) { |
| + f := newImportFixture(t) |
| + code, out := beginImport(t, f, importHost, importSHA, 10) |
| + a, ta := f.requireBegun(t, code, out, importHost) |
| + code, out = beginImport(t, f, "new.shop.example.com", importSHA, 10) |
| + b, tb := f.requireBegun(t, code, out, "new.shop.example.com") |
| + if a.Seed.ID == b.Seed.ID || ta == tb || a.Seed.VerifyHash == b.Seed.VerifyHash { |
| + t.Fatalf("two begins share a seed or a token: %+v %+v", a.Seed, b.Seed) |
| + } |
| +} |
| + |
| +func TestImportBeginRefusals(t *testing.T) { |
| + f := newImportFixture(t) |
| + const ordinary = "a seeded instance is an ordinary instance. Drop --kind, --addr and --role." |
| + const size = "bytes must be more than 0 and at most 4 GiB" |
| + const hash = "sha256 must be 64 lowercase hex characters" |
| + const replaces = "replaces must name the address the import takes over, as host:port" |
| + for _, c := range []struct { |
| + body string |
| + code int |
| + want string |
| + }{ |
| + {importBody("a.shop.example.com", importSHA, 0, "127.0.0.1:1"), http.StatusBadRequest, size}, |
| + {importBody("a.shop.example.com", importSHA, -1, "127.0.0.1:1"), http.StatusBadRequest, size}, |
| + {importBody("a.shop.example.com", importSHA, 4294967297, "127.0.0.1:1"), http.StatusRequestEntityTooLarge, size}, |
| + {importBody("a.shop.example.com", "ABC", 10, "127.0.0.1:1"), http.StatusBadRequest, hash}, |
| + {importBody("a.shop.example.com", strings.Repeat("A", 64), 10, "127.0.0.1:1"), http.StatusBadRequest, hash}, |
| + {importBody("a.shop.example.com", strings.Repeat("a", 63), 10, "127.0.0.1:1"), http.StatusBadRequest, hash}, |
| + {importBody("a.shop.example.com", strings.Repeat("g", 64), 10, "127.0.0.1:1"), http.StatusBadRequest, hash}, |
| + {importBody("a.shop.example.com", importSHA, 10, "nope"), http.StatusBadRequest, replaces}, |
| + {importBody("a.shop.example.com", importSHA, 10, ":8401"), http.StatusBadRequest, replaces}, |
| + {importBody("a.shop.example.com", importSHA, 10, "127.0.0.1:http"), http.StatusBadRequest, replaces}, |
| + {importBody("a.shop.example.com", importSHA, 10, "127.0.0.1:0"), http.StatusBadRequest, replaces}, |
| + {importBody("a.shop.example.com", importSHA, 10, "127.0.0.1:65536"), http.StatusBadRequest, replaces}, |
| + {importBody("a.shop.example.com", importSHA, 10, ""), http.StatusBadRequest, replaces}, |
| + {`{"host":"a.shop.example.com","kind":"service","import":{"sha256":"` + importSHA + `","bytes":10,"replaces":"127.0.0.1:1"}}`, http.StatusBadRequest, ordinary}, |
| + {`{"host":"a.shop.example.com","addrs":["127.0.0.1:9"],"import":{"sha256":"` + importSHA + `","bytes":10,"replaces":"127.0.0.1:1"}}`, http.StatusBadRequest, ordinary}, |
| + // One seed door per create. |
| + {`{"host":"a.shop.example.com","from":{"host":"main.shop.example.com"},"import":{"sha256":"` + importSHA + `","bytes":10,"replaces":"127.0.0.1:1"}}`, http.StatusBadRequest, ordinary}, |
| + } { |
| + if code, out := f.create(t, c.body); code != c.code || str(t, out, "error") != c.want { |
| + t.Errorf("%s = %d %v, want %d %q", c.body, code, out, c.code, c.want) |
| + } |
| + f.requireNoRecord(t, "a.shop.example.com") |
| + } |
| + // An addr with a host name and the largest size are taken. |
| + code, out := f.create(t, importBody(importHost, importSHA, 4294967296, "legacy.internal:8401")) |
| + f.requireBegun(t, code, out, importHost) |
| +} |
| + |
| +func TestImportBeginNeedsOneBox(t *testing.T) { |
| + f := newImportFixture(t) |
| + seedReadyBox(t, f.fx, "kass-2", "", 1, "v0.27.0", "v0.27.0", "nested", false) |
| + code, out := beginImport(t, f, importHost, importSHA, 10) |
| + if code != http.StatusConflict || str(t, out, "error") != "the home region has more than one box. An import needs a region with one box for now." { |
| + t.Fatalf("= %d %v", code, out) |
| + } |
| + f.requireNoRecord(t, importHost) |
| +} |
| + |
| +func TestImportBeginRefusedBeforeTheFloor(t *testing.T) { |
| + f := newImportFixture(t) |
| + seedReadyBox(t, f.fx, "kass-1", "", 0, "v0.27.0", "v0.27.0", "nested", false) |
| + code, out := beginImport(t, f, importHost, importSHA, 10) |
| + if code != http.StatusConflict || !strings.Contains(str(t, out, "error"), "below the seeds floor") { |
| + t.Fatalf("= %d %v", code, out) |
| + } |
| + f.requireNoRecord(t, importHost) |
| +} |
| + |
| +func TestImportBeginRefusedWithClaimsArmed(t *testing.T) { |
| + f := newImportFixture(t) |
| + f.fx.srv.cfg.HostClaim = true |
| + code, out := beginImport(t, f, importHost, importSHA, 10) |
| + if code != http.StatusConflict || str(t, out, "error") != "This console runs with claim control on. Seeded instances are not supported here yet." { |
| + t.Fatalf("= %d %v", code, out) |
| + } |
| + f.requireNoRecord(t, importHost) |
| +} |
| + |
| +// The import is homed in this console's own region: its snapshot goes to |
| +// this region's data bucket. |
| +func TestImportBeginRefusesAnotherRegion(t *testing.T) { |
| + f := newImportFixture(t) |
| + seedReadyBox(t, f.fx, "far-1", "us", 1, "v0.27.0", "v0.27.0", "nested", false) |
| + code, out := f.create(t, `{"host":"old.shop.example.com","region":"us","import":{"sha256":"`+importSHA+`","bytes":10,"replaces":"127.0.0.1:8401"}}`) |
| + if code != http.StatusConflict || str(t, out, "error") != "old.shop.example.com keeps its data in region us. This console can only read data kept in the home region." { |
| + t.Fatalf("= %d %v", code, out) |
| + } |
| + f.requireNoRecord(t, importHost) |
| +} |
| + |
| +func TestImportBeginNeedsADataBucket(t *testing.T) { |
| + f := newImportFixture(t) |
| + f.fx.srv.cfg.SeedData = nil |
| + code, out := beginImport(t, f, importHost, importSHA, 10) |
| + if code != http.StatusServiceUnavailable || str(t, out, "error") != "this console has no data bucket set up, so it cannot create a seeded instance." { |
| + t.Fatalf("= %d %v", code, out) |
| + } |
| + f.requireNoRecord(t, importHost) |
| +} |
| + |
| +func TestImportBeginRefusesAPooledApp(t *testing.T) { |
| + f := newImportFixture(t) |
| + m := fromManifest(false) |
| + m.Pool = "shared" |
| + enableInstances(t, f.fx, fromApp, m) |
| + code, out := beginImport(t, f, importHost, importSHA, 10) |
| + if code != http.StatusConflict || str(t, out, "error") != "shop places its instances in a pool or on a fleet, so it cannot take a seeded instance." { |
| + t.Fatalf("= %d %v", code, out) |
| + } |
| + f.requireNoRecord(t, importHost) |
| +} |
| + |
| +// A failed record write answers SD-N67 and leaves the host free. |
| +func TestImportBeginRecordWriteFails(t *testing.T) { |
| + f := newImportFixture(t) |
| + fail := &failOnceOn{Store: f.fx.srv.cfg.Releases, key: provision.RecordKey("bab", fromApp, importHost)} |
| + fail.armed.Store(true) |
| + f.fx.srv.cfg.Releases = fail |
| + code, out := beginImport(t, f, importHost, importSHA, 10) |
| + if code != http.StatusBadGateway || str(t, out, "error") != "could not store the upload. Try again." { |
| + t.Fatalf("= %d %v", code, out) |
| + } |
| + f.requireNoRecord(t, importHost) |
| + code, out = beginImport(t, f, importHost, importSHA, 10) |
| + f.requireBegun(t, code, out, importHost) |
| +} |
| + |
| +// With private storage the import gets the derived environment and a new |
| +// store marked with its seed, named on the 201 as the ordinary create does. |
| +func TestImportBeginPrivateStorage(t *testing.T) { |
| + f := newImportFixture(t) |
| + enableInstances(t, f.fx, fromApp, fromManifest(true)) |
| + code, out := f.create(t, `{"host":"old.shop.example.com","environment":"main","import":{"sha256":"`+importSHA+`","bytes":10,"replaces":"127.0.0.1:8401"}}`) |
| + if code != http.StatusConflict || str(t, out, "error") != "a seeded instance gets its own environment and an empty store" { |
| + t.Fatalf("explicit environment = %d %v", code, out) |
| + } |
| + f.requireNoRecord(t, importHost) |
| + |
| + code, out = beginImport(t, f, importHost, importSHA, 10) |
| + rec, _ := f.requireBegun(t, code, out, importHost) |
| + if rec.Environment != "old" { |
| + t.Fatalf("record environment = %q, want the host's own", rec.Environment) |
| + } |
| + store, _, err := objstore.GetIn(context.Background(), f.fx.rel, "bab", fromApp, "old") |
| + if err != nil || store == nil || store.Declared == nil || store.Declared.Seed != rec.Seed.ID || storeBucketName(store) == "" { |
| + t.Fatalf("old store = %+v, %v; want one marked with seed %s", store, err, rec.Seed.ID) |
| + } |
| + if str(t, out, "private_store_bucket") != storeBucketName(store) { |
| + t.Fatalf("201 private_store_bucket = %v, want %s", out["private_store_bucket"], storeBucketName(store)) |
| + } |
| +} |
| + |
| +// An existing store for the derived environment is refused before the |
| +// record is written. |
| +func TestImportBeginRefusesATakenStore(t *testing.T) { |
| + f := newImportFixture(t) |
| + enableInstances(t, f.fx, fromApp, fromManifest(true)) |
| + foreign := grantedStore("carlos-bab-shop-old") |
| + if _, err := objstore.PutIn(context.Background(), f.fx.rel, "bab", fromApp, "old", foreign, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + code, out := beginImport(t, f, importHost, importSHA, 10) |
| + if code != http.StatusConflict || str(t, out, "error") != "environment old already has a store (carlos-bab-shop-old). A seeded instance needs a new, empty one." { |
| + t.Fatalf("= %d %v", code, out) |
| + } |
| + f.requireNoRecord(t, importHost) |
| +} |
| + |
| +// The store gates that write nothing refuse before the record is written |
| +// (ruling R3, preflight M2). |
| +func TestImportBeginStoreGatesRefuseBeforeTheRecord(t *testing.T) { |
| + f := newImportFixture(t) |
| + enableInstances(t, f.fx, fromApp, fromManifest(true)) |
| + f.fx.srv.cfg.SecretsPub = "" |
| + code, out := beginImport(t, f, importHost, importSHA, 10) |
| + if code != http.StatusServiceUnavailable || str(t, out, "error") != "this console has no secrets key (CARLOS_SECRETS_PUBKEY) — it cannot deliver a store credential, so it will not mint one" { |
| + t.Fatalf("= %d %v", code, out) |
| + } |
| + f.requireNoRecord(t, importHost) |
| +} |
| + |
| +func grantedStore(bucket string) *objstore.Record { |
| + at := time.Now().UTC().Add(-time.Hour) |
| + return &objstore.Record{ |
| + Declared: &objstore.Declared{EnvPrefix: "S3", At: at, By: "bob@keymail.dev"}, |
| + Grant: &objstore.GrantBlock{Backend: "mem", Bucket: bucket, Source: objstore.GrantSourceAccountApproval, At: at, By: "operator@keymail.dev"}, |
| + } |
| +} |
| + |
| +// A store walk refused after the record was written rolls the import back |
| +// and finishes it before answering, so the same begin can run again at once. |
| +func TestImportBeginStoreWalkRefusalFreesTheHost(t *testing.T) { |
| + f := newImportFixture(t) |
| + enableInstances(t, f.fx, fromApp, fromManifest(true)) |
| + // Another environment's store already uses the name the walk derives. |
| + if _, err := objstore.PutIn(context.Background(), f.fx.rel, "bab", fromApp, "staging", grantedStore("carlos-bab-shop-old"), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + code, out := beginImport(t, f, importHost, importSHA, 10) |
| + if code != http.StatusConflict || str(t, out, "error") != "another store already uses this instance's storage names (app and environment names are joined with \"-\"), so the instance was not created. use a different host name" { |
| + t.Fatalf("= %d %v", code, out) |
| + } |
| + f.requireNoRecord(t, importHost) |
| + if err := f.fx.rel.Delete(context.Background(), objstore.KeyIn("bab", fromApp, "staging")); err != nil { |
| + t.Fatal(err) |
| + } |
| + code, out = beginImport(t, f, importHost, importSHA, 10) |
| + f.requireBegun(t, code, out, importHost) |
| +} |
| + |
| +// storeOnRecordWrite writes a foreign, granted store for env right after |
| +// the record's create lands: the store another writer made between the |
| +// door's check and its walk. |
| +type storeOnRecordWrite struct { |
| + release.Store |
| + key, env string |
| + once sync.Once |
| +} |
| + |
| +func (s *storeOnRecordWrite) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) { |
| + tag, err := s.Store.Put(ctx, key, body, ifMatch) |
| + if err == nil && key == s.key && ifMatch == "" { |
| + s.once.Do(func() { |
| + if _, perr := objstore.PutIn(ctx, s.Store, "bab", fromApp, s.env, grantedStore("carlos-bab-shop-"+s.env), ""); perr != nil { |
| + panic(perr) |
| + } |
| + }) |
| + } |
| + return tag, err |
| +} |
| + |
| +func (s *storeOnRecordWrite) DeleteIfMatch(ctx context.Context, key, etag string) error { |
| + return release.DeleteIfMatch(ctx, s.Store, key, etag) |
| +} |
| + |
| +// The walk's own re-check under the store lock (errSeedStoreTaken) answers |
| +// SD-N34, rolls the import back and frees the host; the other writer's |
| +// store is left as it is. |
| +func TestImportBeginStoreTakenUnderTheLock(t *testing.T) { |
| + f := newImportFixture(t) |
| + enableInstances(t, f.fx, fromApp, fromManifest(true)) |
| + f.fx.srv.cfg.Releases = &storeOnRecordWrite{Store: f.fx.rel, key: provision.RecordKey("bab", fromApp, importHost), env: "old"} |
| + code, out := beginImport(t, f, importHost, importSHA, 10) |
| + if code != http.StatusConflict || str(t, out, "error") != "environment old already has a store (carlos-bab-shop-old). A seeded instance needs a new, empty one." { |
| + t.Fatalf("= %d %v", code, out) |
| + } |
| + f.requireNoRecord(t, importHost) |
| + store, _, err := objstore.GetIn(context.Background(), f.fx.rel, "bab", fromApp, "old") |
| + if err != nil || store == nil || store.Grant == nil || store.Grant.Bucket != "carlos-bab-shop-old" { |
| + t.Fatalf("the other writer's store = %+v, %v; want it kept", store, err) |
| + } |
| +} |
| + |
| +// apexOnBucket creates the app label as the store walk makes its bucket: |
| +// a sibling app taking the host's platform apex mid-begin. It records the |
| +// seed the store was declared for, as the walk sees it. |
| +type apexOnBucket struct { |
| + *objstore.MemProvisioner |
| + f fromFixture |
| + label string |
| + declared string |
| +} |
| + |
| +func (p *apexOnBucket) EnsureBucket(ctx context.Context, name string) (string, error) { |
| + if store, _, err := objstore.GetIn(ctx, p.f.fx.rel, "bab", fromApp, p.label); err == nil && store != nil && store.Declared != nil { |
| + p.declared = store.Declared.Seed |
| + } |
| + if _, err := p.f.fx.st.CreateAppIn(OpsAccountID, p.label, "paul@keymail.dev", time.Now()); err != nil { |
| + return "", err |
| + } |
| + return p.MemProvisioner.EnsureBucket(ctx, name) |
| +} |
| + |
| +// Preflight m11 and M3: a begin on a private-storage app whose host is |
| +// claimed as a sibling app's apex after the record was written. The store |
| +// was made for this seed; the begin rolls the import back, the finish |
| +// tears that store down and deletes the record, and the create door's own |
| +// sentence is answered. |
| +func TestImportBeginSiblingApexTearsDownTheStore(t *testing.T) { |
| + f := newImportFixture(t) |
| + f.fx.srv.cfg.AppsDomain = "apps.example.net" |
| + m := fromManifest(true) |
| + m.Domain = "bab.apps.example.net" |
| + enableInstances(t, f.fx, fromApp, m) |
| + p := &apexOnBucket{MemProvisioner: f.fake, f: f, label: "old"} |
| + f.fx.srv.cfg.StoreProvisioner = p |
| + var seen *provision.Record |
| + f.fx.srv.cfg.Releases = &recordSpy{Store: f.fx.rel, key: provision.RecordKey("bab", fromApp, "old.bab.apps.example.net"), seen: &seen} |
| + |
| + code, out := beginImport(t, f, "old.bab.apps.example.net", importSHA, 10) |
| + if code != http.StatusConflict || str(t, out, "error") != "old.bab.apps.example.net is app old's platform apex — it was created while this instance was being written; the instance was not kept" { |
| + t.Fatalf("= %d %v", code, out) |
| + } |
| + if seen == nil || seen.Seed == nil || !seeds.ValidID(seen.Seed.ID) || p.declared != seen.Seed.ID { |
| + t.Fatalf("the store was declared for seed %q; the record written was %+v", p.declared, seen) |
| + } |
| + f.requireNoRecord(t, "old.bab.apps.example.net") |
| + if store, _, err := objstore.GetIn(context.Background(), f.fx.rel, "bab", fromApp, "old"); err != nil || store != nil { |
| + t.Fatalf("the seed's store record = %+v, %v; want it torn down", store, err) |
| + } |
| + if _, ok := f.fake.Buckets["carlos-bab-shop-old"]; ok { |
| + t.Fatal("the seed's bucket survived the finish") |
| + } |
| + if len(f.fake.Buckets) != 0 { |
| + t.Fatalf("buckets left = %v", f.fake.Buckets) |
| + } |
| +} |
| + |
| +// recordSpy keeps the first create-only write of key's body. |
| +type recordSpy struct { |
| + release.Store |
| + key string |
| + seen **provision.Record |
| +} |
| + |
| +func (s *recordSpy) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) { |
| + if key == s.key && ifMatch == "" && *s.seen == nil { |
| + var rec provision.Record |
| + if err := json.Unmarshal(body, &rec); err == nil { |
| + *s.seen = &rec |
| + } |
| + } |
| + return s.Store.Put(ctx, key, body, ifMatch) |
| +} |
| + |
| +func (s *recordSpy) DeleteIfMatch(ctx context.Context, key, etag string) error { |
| + return release.DeleteIfMatch(ctx, s.Store, key, etag) |
| +} |
| + |
| +// Begin uploads nothing: the snapshot is the upload's (Task 2). |
| +func TestImportBeginUploadsNothing(t *testing.T) { |
| + f := newImportFixture(t) |
| + code, out := beginImport(t, f, importHost, importSHA, 10) |
| + f.requireBegun(t, code, out, importHost) |
| + f.requireNothingUploaded(t) |
| + f.requireClean(t) |
| +} |