package hostops import ( "context" "encoding/json" "errors" "github.com/carlosframework/platform/internal/release" "testing" "time" ) func TestIngestSharedDrainAndExpiredIssuedEffect(t *testing.T) { w, raw, now := testWriter(t) ctx := t.Context() seedAdmission(t, raw, "bab", "demo", openAdmission("app")) var handles []*IngestHandle var settles []func(string, error) error for _, id := range []string{"ingest-a", "ingest-b"} { h, err := w.RegisterIngest(ctx, "bab", "demo", "app", id, testOwner) if err != nil { t.Fatal(err) } handles = append(handles, h) pctx, settle, err := h.start.Runner(w.now).Issue(ctx, Effect{ID: id, Resource: "app-sql:" + id, Action: sqlIngestAction, Inputs: map[string]string{"sql_database": "local"}}) if err != nil { t.Fatal(err) } settles = append(settles, settle) p, err := PrepareSQLPermit(pctx, raw, "bab", "demo", "app") if err != nil { t.Fatal(err) } if err = RequireAppMutation(pctx, raw, "bab", "demo", "app"); err == nil { t.Fatal("SQL permit granted generic app write") } if generic, err := LivePermits(pctx, raw); err != nil || len(generic) != 0 { t.Fatalf("SQL-only permit leaked to backend: %v %v", generic, err) } if err = p.Check(ctx); err != nil { t.Fatal(err) } } op, err := w.BeginExclusive(ctx, "bab", "demo", "app", AppOperation{ID: "delete", Kind: OpDeleting, AppID: "app"}, testOwner) if err != nil { t.Fatal(err) } later := now().Add(2 * RegistrationTTL) if err = op.Drain(ctx, later, nil); !errors.Is(err, ErrPending) { t.Fatalf("expired issued effects drained: %v", err) } if err = settles[0]("sql-joined", nil); err != nil { t.Fatal(err) } if err = op.Drain(ctx, later, nil); !errors.Is(err, ErrPending) { t.Fatalf("drain ignored second ingest: %v", err) } if err = settles[1]("sql-joined", nil); err != nil { t.Fatal(err) } if err = op.Drain(ctx, later, nil); err != nil { t.Fatal(err) } if _, err = w.RegisterIngest(ctx, "bab", "demo", "app", "late", testOwner); !errors.Is(err, ErrPending) { t.Fatalf("closed admission: %v", err) } _ = handles } type sqlProof struct { fail bool seen SQLReceiptScope } func (p *sqlProof) FenceSQL(_ context.Context, s SQLReceiptScope) error { p.seen = s if p.fail { return errors.New("database unavailable") } return nil } func TestIngestRecoveryRequiresSQLFence(t *testing.T) { w, raw, _ := testWriter(t) ctx := t.Context() seedAdmission(t, raw, "bab", "demo", openAdmission("app")) h, err := w.RegisterIngest(ctx, "bab", "demo", "app", "ingest", testOwner) if err != nil { t.Fatal(err) } _, settle, err := h.start.Runner(w.now).Issue(ctx, Effect{ID: "ingest", Resource: "app-sql:ingest", Action: sqlIngestAction, Inputs: map[string]string{"sql_database": "original"}}) if err != nil { t.Fatal(err) } _ = settle("", errors.New("console vanished")) proof := &sqlProof{fail: true} if err = w.RecoverIngest(ctx, "bab", "demo", "app", "ingest", proof); err == nil { t.Fatal("missing database settled") } a, _ := ReadAdmission(ctx, raw, "bab", "demo", "app") if a.Value.Effects["ingest"].State != "uncertain" { t.Fatal("uncertainty cleared") } proof.fail = false if err = w.RecoverIngest(ctx, "bab", "demo", "app", "ingest", proof); err != nil { t.Fatal(err) } if proof.seen.Database != "original" || proof.seen.Owner != testOwner { t.Fatal("recovery scope changed") } a, _ = ReadAdmission(ctx, raw, "bab", "demo", "app") if len(a.Value.Registrations) != 0 || a.Value.Effects["ingest"].ResultReference != "sql-fenced" { t.Fatal("recovery did not drain") } } func TestPreparedSQLCancellationDeadlineAndRevocation(t *testing.T) { w, raw, _ := testWriter(t) ctx, cancel := context.WithCancel(t.Context()) defer cancel() seedAdmission(t, raw, "bab", "demo", openAdmission("app")) h, err := w.RegisterIngest(ctx, "bab", "demo", "app", "ingest", testOwner) if err != nil { t.Fatal(err) } pctx, settle, err := h.start.Runner(w.now).Issue(ctx, Effect{ID: "ingest", Resource: "app-sql:ingest", Action: sqlIngestAction, Inputs: map[string]string{"sql_database": "db"}}) if err != nil { t.Fatal(err) } p, err := PrepareSQLPermit(pctx, raw, "bab", "demo", "app") if err != nil { t.Fatal(err) } if _, err = PrepareSQLPermit(pctx, raw, "bab", "other", "app"); err == nil { t.Fatal("cross-app accepted") } p.deadline = time.Now().Add(-time.Second) if !errors.Is(p.Check(ctx), context.DeadlineExceeded) { t.Fatal("deadline ignored") } p.deadline = time.Now().Add(time.Second) cancel() if !errors.Is(p.Check(context.Background()), context.Canceled) { t.Fatal("request cancellation ignored") } _ = settle("sql-joined", nil) if p.Check(context.Background()) == nil { t.Fatal("settled permit survived") } } func TestPreparedSQLChildBlocksRestoreRecovery(t *testing.T) { w, raw, _ := testWriter(t) ctx := t.Context() seedAdmission(t, raw, "bab", "demo", openAdmission("app")) h, err := w.BeginExclusive(ctx, "bab", "demo", "app", AppOperation{ID: "restore", Kind: OpAppMutation, AppID: "app", Intent: map[string]string{"action": "restore"}}, testOwner) if err != nil { t.Fatal(err) } if err = h.PrepareRestore(ctx, "old-marker", 1); err != nil { t.Fatal(err) } pctx, _, err := h.Runner(w.now).Issue(ctx, Effect{ID: "restore", Resource: OpAppMutation, Action: "restore"}) if err != nil { t.Fatal(err) } p, err := PrepareSQLPermit(pctx, raw, "bab", "demo", "app") if err != nil { t.Fatal(err) } if err = p.BindTransaction("original-db"); err != nil { t.Fatal(err) } child := p.Receipt() if child.Effect == "restore" || child.Operation != "restore" { t.Fatalf("not a SQL child: %+v", child) } if err = w.RecoverMutation(ctx, "bab", "demo", "app", "restore"); !errors.Is(err, ErrPending) { t.Fatalf("restore escaped issued SQL child: %v", err) } a, _ := ReadAdmission(ctx, raw, "bab", "demo", "app") if a.Value.Op == nil || a.Value.Effects[child.Effect].State != "issued" { t.Fatal("restore released child obligation") } proof := &sqlProof{} if err = w.RecoverSQLTransaction(ctx, "bab", "demo", "app", child.Effect, proof); err != nil { t.Fatal(err) } if err = w.RecoverMutation(ctx, "bab", "demo", "app", "restore"); err != nil { t.Fatal(err) } a, _ = ReadAdmission(ctx, raw, "bab", "demo", "app") if a.Value.Op != nil { t.Fatal("restore did not finish after SQL proof") } } func TestIngestPurgeJournalsBothHostlessDrainObligations(t *testing.T) { w, raw, clock := testWriter(t) ctx := t.Context() seedAdmission(t, raw, "bab", "demo", openAdmission("app")) var settles []func(string, error) error for _, id := range []string{"one", "two"} { h, err := w.RegisterIngest(ctx, "bab", "demo", "app", id, testOwner) if err != nil { t.Fatal(err) } _, settle, err := h.start.Runner(w.now).Issue(ctx, Effect{ID: id, Resource: "app-sql:" + id, Action: sqlIngestAction, Inputs: map[string]string{"sql_database": "db"}}) if err != nil { t.Fatal(err) } settles = append(settles, settle) } key := release.AppTombstoneKey("bab", "demo") tag, err := raw.Put(ctx, key, []byte(`{}`), "") if err != nil { t.Fatal(err) } journal := PurgeJournal{V: 1, Purge: Purge{ID: "purge", Phase: "reserved"}, AppID: "app", Account: "bab", App: "demo", Hosts: map[string]PurgeHost{}, Tombstone: PurgeObject{Key: key, ETag: tag, Body: json.RawMessage(`{}`)}} if _, err = w.ReservePurge(ctx, journal, testOwner, clock()); err != nil { t.Fatal(err) } if _, err = w.BeginPurge(ctx, "bab", "demo", "app", "purge", testOwner); err != nil { t.Fatal(err) } later := clock().Add(2 * RegistrationTTL) if err = w.DrainPurgeRegistrations(ctx, "bab", "demo", "app", "purge", later, nil); !errors.Is(err, ErrPending) { t.Fatalf("expired SQL obligations drained: %v", err) } if err = settles[0]("sql-joined", nil); err != nil { t.Fatal(err) } if err = w.DrainPurgeRegistrations(ctx, "bab", "demo", "app", "purge", later, nil); !errors.Is(err, ErrPending) { t.Fatalf("second SQL obligation ignored: %v", err) } if err = settles[1]("sql-joined", nil); err != nil { t.Fatal(err) } if err = w.DrainPurgeRegistrations(ctx, "bab", "demo", "app", "purge", later, nil); err != nil { t.Fatal(err) } journal, _, err = ReadPurgeJournal(ctx, raw, "bab", "demo", "purge", "app") if err != nil { t.Fatal(err) } if len(journal.IngestRegistrations) != 2 || len(journal.Hosts) != 0 { t.Fatalf("hostless evidence missing or invented host: %+v", journal) } }