diff --git a/internal/controlplane/api/creator.go b/internal/controlplane/api/creator.go index 26cc45e..2980f89 100644 --- a/internal/controlplane/api/creator.go +++ b/internal/controlplane/api/creator.go @@ -1248,7 +1248,7 @@ func newAnonymousBrowser(ctx context.Context, store *hub.Store) (anonymousBrowse if template.Fingerprint.Seed < 1 { template.Fingerprint.Seed = time.Now().UnixNano()%2147483646 + 1 } - environment := hub.EnvironmentContext{Env: template, BindingVersion: 1} + environment := hub.EnvironmentContext{Env: template, ProfileID: template.Alias, BindingVersion: 1} payload := gatewayCreatePayload(environment, "", gatewayNetworkExit{}) status, body, callErr := gatewayCall(ctx, gateway, http.MethodPost, "/v1/browsers", payload, gatewayLongTimeout) if callErr == nil && status == http.StatusCreated { diff --git a/internal/controlplane/api/creator_anonymous_browser_test.go b/internal/controlplane/api/creator_anonymous_browser_test.go new file mode 100644 index 0000000..bbd50ea --- /dev/null +++ b/internal/controlplane/api/creator_anonymous_browser_test.go @@ -0,0 +1,134 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "regexp" + "strings" + "testing" + + hub "git.ipao.vip/rogee/creator-hub/internal/environment" +) + +func TestAnonymousBrowserUsesIndependentProfile(t *testing.T) { + databaseURL := os.Getenv("CREATORHUB_POSTGRES_TEST_URL") + if databaseURL == "" { + t.Skip("set CREATORHUB_POSTGRES_TEST_URL to run PostgreSQL integration coverage") + } + for _, withTemplate := range []bool{false, true} { + name := "without_template" + if withTemplate { + name = "with_template" + } + t.Run(name, func(t *testing.T) { + ctx := context.Background() + store, err := hub.Open(ctx, isolatedControlPlaneDatabaseURL(t, databaseURL)) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = store.Close() }) + + var payloads []struct { + Alias string `json:"alias"` + ProfileID string `json:"profile_id"` + } + var purgedProfiles []string + profilePattern := regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,127}$`) + gateway := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodPost && r.URL.Path == "/v1/browsers": + var payload struct { + Alias string `json:"alias"` + ProfileID string `json:"profile_id"` + } + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + t.Error(err) + w.WriteHeader(http.StatusBadRequest) + return + } + payloads = append(payloads, payload) + if !profilePattern.MatchString(payload.ProfileID) { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"detail":"profile_id is invalid"}`)) + return + } + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": strings.Repeat("a", 64), "network_id": "native-" + strings.Repeat("b", 32), + "alias": payload.Alias, "binding_version": 1, "state": "running", + }) + case r.Method == http.MethodGet && r.URL.Path == "/v1/browsers": + _, _ = w.Write([]byte(`[]`)) + case r.Method == http.MethodDelete && strings.HasPrefix(r.URL.Path, "/v1/browsers/"): + var payload struct { + PurgeProfile bool `json:"purge_profile"` + } + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + t.Error(err) + } + if !payload.PurgeProfile { + t.Error("anonymous cleanup must purge its temporary profile") + } + // The gateway purges the profile saved in the runtime selected by alias. + alias := strings.TrimPrefix(r.URL.Path, "/v1/browsers/") + for _, created := range payloads { + if created.Alias == alias { + purgedProfiles = append(purgedProfiles, created.ProfileID) + break + } + } + w.WriteHeader(http.StatusNoContent) + default: + http.NotFound(w, r) + } + })) + defer gateway.Close() + if _, err := store.CreateGateway(ctx, "gw-profile-test", gateway.URL, "unit-test-gateway-token"); err != nil { + t.Fatal(err) + } + var template hub.EnvironmentContext + if withTemplate { + template, err = store.CreateStandaloneEnv(ctx, "gw-profile-test", hub.Fingerprint{}, 4096) + if err != nil { + t.Fatal(err) + } + } + + for i := 0; i < 2; i++ { + lease, err := newAnonymousBrowser(ctx, store) + if err != nil { + t.Fatalf("anonymous browser creation failed: %v", err) + } + if err := lease.close(); err != nil { + t.Fatal(err) + } + payload := payloads[i] + if payload.ProfileID != payload.Alias || !strings.HasPrefix(payload.ProfileID, "anon-") { + t.Fatalf("anonymous profile must use its own alias: %+v", payload) + } + if purgedProfiles[i] != payload.ProfileID { + t.Fatalf("cleanup purged another profile: got %q want %q", purgedProfiles[i], payload.ProfileID) + } + if withTemplate && payload.ProfileID == template.ProfileID { + t.Fatal("anonymous browser reused an existing environment profile") + } + } + if payloads[0].ProfileID == payloads[1].ProfileID { + t.Fatal("anonymous browsers must not share a profile") + } + if withTemplate { + stored, err := store.GetEnvironmentContext(ctx, template.Alias) + if err != nil { + t.Fatal(err) + } + if stored.ProfileID != template.ProfileID || stored.Fingerprint.Seed != template.Fingerprint.Seed { + t.Fatal("anonymous browser changed the existing environment identity") + } + } + }) + } +}