package activator import ( "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" "github.com/carlosframework/platform/internal/instancekey" "github.com/carlosframework/platform/internal/registry" ) // adoptionFixture is one claim-launched process's control socket, served // in memory: its launch state, and every record handed to the publisher. type adoptionFixture struct { mux *http.ServeMux launch AdoptionLaunch hasLaunch bool published [][]byte } func newAdoptionFixture(t *testing.T, rt registry.Route, tok, key, source string) *adoptionFixture { t.Helper() f := &adoptionFixture{hasLaunch: true} f.launch = AdoptionLaunch{ Writer: instancekey.Binding{Host: rt.Host, Incarnation: "inc-1", Session: 3, Generation: "g3"}, KeySHA256: instancekey.Digest(key), Source: source, Dir: t.TempDir(), Publish: func(rec []byte) { f.published = append(f.published, rec) }, } f.mux = http.NewServeMux() (&ExecBackend{}).MountInstanceKeyAdoption(f.mux, rt, func() (string, error) { return tok, nil }, func() (AdoptionLaunch, bool) { return f.launch, f.hasLaunch }) return f } func (f *adoptionFixture) put(token, body string) int { req := httptest.NewRequest(http.MethodPut, instanceKeyAdoptionPath, strings.NewReader(body)) if token != "" { req.Header.Set("Authorization", "Bearer "+token) } w := httptest.NewRecorder() f.mux.ServeHTTP(w, req) return w.Code } func adoptionBody(key, source string) string { return `{"sha256":"` + instancekey.Digest(key) + `","source":"` + source + `"}` } // Rulings 20 and 21: the agent records the launch's own writer and the key // the launch delivered, from its own launch state. A request cannot name a // writer, a digest or a source the launch did not have. func TestInstanceKeyAdoptionRecordsTheLaunchesWriter(t *testing.T) { tok := strings.Repeat("f", 64) key := strings.Repeat("K", 43) + "=" rt := registry.Route{Host: "shop.example.com", Kind: "instance"} f := newAdoptionFixture(t, rt, tok, key, instancekey.SourcePlatform) recPath := filepath.Join(f.launch.Dir, rt.Host+instancekey.AdoptionSuffix) for _, tc := range []struct { name, token, body string want int }{ {"no bearer", "", adoptionBody(key, "platform"), http.StatusUnauthorized}, {"another host's bearer", strings.Repeat("0", 64), adoptionBody(key, "platform"), http.StatusUnauthorized}, {"not a digest", tok, `{"sha256":"abc","source":"platform"}`, http.StatusBadRequest}, {"an unknown source", tok, adoptionBody(key, "own"), http.StatusBadRequest}, {"a session in the request", tok, `{"sha256":"` + instancekey.Digest(key) + `","source":"platform","session":9}`, http.StatusBadRequest}, // Astra's P3: the body is one object and nothing after it. A // trailing } or ] passed Decoder.More; the second Decode must // answer io.EOF. {"a trailing }", tok, adoptionBody(key, "platform") + "}", http.StatusBadRequest}, {"a trailing ]", tok, adoptionBody(key, "platform") + "]", http.StatusBadRequest}, {"trailing garbage", tok, adoptionBody(key, "platform") + " xyz", http.StatusBadRequest}, {"a second object", tok, adoptionBody(key, "platform") + adoptionBody(key, "platform"), http.StatusBadRequest}, {"another key", tok, adoptionBody("some-other-key", "platform"), http.StatusConflict}, {"another source", tok, adoptionBody(key, "config"), http.StatusConflict}, } { if got := f.put(tc.token, tc.body); got != tc.want { t.Fatalf("%s: %d, want %d", tc.name, got, tc.want) } if _, err := os.Stat(recPath); !os.IsNotExist(err) || len(f.published) != 0 { t.Fatalf("%s: a record was written or published", tc.name) } } want := instancekey.AdoptionRecord(f.launch.Writer, instancekey.SourcePlatform, instancekey.Digest(key)) for i := 0; i < 2; i++ { // the helper reports on every process start if got := f.put(tok, adoptionBody(key, "platform")); got != http.StatusNoContent { t.Fatalf("report %d: %d, want 204", i, got) } rec, err := os.ReadFile(recPath) if err != nil || string(rec) != string(want) { t.Fatalf("report %d: record is %d bytes, want %d, %v", i, len(rec), len(want), err) } if st, _ := os.Stat(recPath); st.Mode().Perm() != 0o600 { t.Fatalf("record mode = %o, want 600", st.Mode().Perm()) } if len(f.published) != i+1 || string(f.published[i]) != string(want) { t.Fatalf("report %d: published %d records", i, len(f.published)) } } // A configured key is recorded the same way, with its source (ruling 20). cfg := newAdoptionFixture(t, rt, tok, "from-config", instancekey.SourceConfig) if got := cfg.put(tok, adoptionBody("from-config", "config")); got != http.StatusNoContent { t.Fatalf("configured key: %d, want 204", got) } if a, err := instancekey.ParseAdoption(cfg.published[0]); err != nil || a.Source != instancekey.SourceConfig || !a.Binds(cfg.launch.Writer) { t.Fatalf("configured key's record = %+v, %v", a, err) } // No launch state (a legacy launch) is the 404 an agent without the // verb answers; a replica refuses (ruling 26). none := newAdoptionFixture(t, rt, tok, key, instancekey.SourcePlatform) none.hasLaunch = false replicaRT := rt replicaRT.Replica = true replica := newAdoptionFixture(t, replicaRT, tok, key, instancekey.SourcePlatform) if got := none.put(tok, adoptionBody(key, "platform")); got != http.StatusNotFound { t.Fatalf("no launch state: %d, want 404", got) } if got := replica.put(tok, adoptionBody(key, "platform")); got != http.StatusConflict { t.Fatalf("replica: %d, want 409", got) } if len(none.published)+len(replica.published) != 0 { t.Fatal("a record was published without a writer") } } // A launch state O6 built wrong (another host's writer, no publisher, or a // writer binding the pinner would refuse) is a 500, with nothing written // and nothing published: never a 204 for a record nobody can read. func TestInstanceKeyAdoptionRefusesAnIncompleteLaunchState(t *testing.T) { tok := strings.Repeat("f", 64) key := strings.Repeat("K", 43) + "=" rt := registry.Route{Host: "shop.example.com", Kind: "instance"} for _, tc := range []struct { name string wreck func(*AdoptionLaunch) }{ {"another host's writer", func(l *AdoptionLaunch) { l.Writer.Host = "other.example.com" }}, {"no publisher", func(l *AdoptionLaunch) { l.Publish = nil }}, {"a zero session", func(l *AdoptionLaunch) { l.Writer.Session = 0 }}, {"no incarnation", func(l *AdoptionLaunch) { l.Writer.Incarnation = "" }}, {"no generation", func(l *AdoptionLaunch) { l.Writer.Generation = "" }}, } { f := newAdoptionFixture(t, rt, tok, key, instancekey.SourcePlatform) tc.wreck(&f.launch) if got := f.put(tok, adoptionBody(key, "platform")); got != http.StatusInternalServerError { t.Fatalf("%s: %d, want 500", tc.name, got) } if _, err := os.Stat(filepath.Join(f.launch.Dir, rt.Host+instancekey.AdoptionSuffix)); !os.IsNotExist(err) || len(f.published) != 0 { t.Fatalf("%s: a record was written or published", tc.name) } } }