diff --git a/changes/unreleased/sysmlapi-client-hardening.fixed.md b/changes/unreleased/sysmlapi-client-hardening.fixed.md new file mode 100644 index 0000000000..bf7a154418 --- /dev/null +++ b/changes/unreleased/sysmlapi-client-hardening.fixed.md @@ -0,0 +1,4 @@ +- **A SysML v2 API server cannot have the bearer token paged on to another host.** A `Link: rel="next"` header naming a page on another server, on another port, or over plaintext is refused with the error a redirect there gets ("the token is for *host* only"); the page is never requested with the token. Without a token the link is followed as before. +- **The repository URL the environment supplies is held to the plaintext rule.** `FLEXO_SYSMLV2_URL` naming an `http://` server off this machine is refused by `%repo`, `%projects`, `%load` and `%publish` before any request goes out — the same refusal `%repo ` gives, lifted the same way by `FLEXO_ALLOW_PLAIN_HTTP=1` — instead of sending `FLEXO_INTEROP_TOKEN` in the clear. +- **A branch read before its first commit is still checked for a moved head.** A change set computed against a branch with no head commit is refused as a stale branch when another writer made the first commit in between, instead of being posted onto the commit it never saw. +- **`%publish` by name reaches the project the session loaded by id.** When the session tracks a project of that name on the server, the publish resolves it by the stored id, so a second project with the same name no longer makes the name ambiguous; `--project` naming another project and the refusal of a shared name no session project matches are unchanged. diff --git a/docs/guide/12-jupyter.md b/docs/guide/12-jupyter.md index e62ef0591a..65c8af1fc6 100644 --- a/docs/guide/12-jupyter.md +++ b/docs/guide/12-jupyter.md @@ -264,7 +264,7 @@ environment of the notebook server: | `FLEXO_SYSMLV2_URL` | `http://localhost:9000` | the SysML v2 API endpoint, by default `http://localhost:8083` | | `FLEXO_INTEROP_TOKEN` | unset | the bearer token; never put it in a cell | | `FLEXO_SYSMLV2_ORG` | unset | the organization, by default `sysmlv2` | -| `FLEXO_ALLOW_PLAIN_HTTP` | unset on this machine | `1` to allow a plaintext `http://` server on another | +| `FLEXO_ALLOW_PLAIN_HTTP` | unset on this machine | `1` to allow a plaintext `http://` server on another, whether `FLEXO_SYSMLV2_URL` or `%repo` names it; the token still never follows a redirect or a next-page link to another server | Then, as the pilot's notebooks do: diff --git a/docs/reference/repl-commands.md b/docs/reference/repl-commands.md index db4fe047c2..5632321f15 100644 --- a/docs/reference/repl-commands.md +++ b/docs/reference/repl-commands.md @@ -124,7 +124,7 @@ into the parts it holds (`car.fl.hub`, `#3.fl`, `car.wheels[2]`). | Command | Description | |---------|-------------| -| `%repo []` | Show or set the base URL of the SysML v2 API server the repository commands address, seeded from `FLEXO_SYSMLV2_URL` (default `http://localhost:8083`). A bearer token, when the server wants one, is read from `FLEXO_INTEROP_TOKEN`; it is never printed, and nothing is persisted. A plaintext `http://` URL off this machine is refused unless `FLEXO_ALLOW_PLAIN_HTTP=1` | +| `%repo []` | Show or set the base URL of the SysML v2 API server the repository commands address, seeded from `FLEXO_SYSMLV2_URL` (default `http://localhost:8083`). A bearer token, when the server wants one, is read from `FLEXO_INTEROP_TOKEN`; it is never printed, and nothing is persisted. A plaintext `http://` URL off this machine is refused unless `FLEXO_ALLOW_PLAIN_HTTP=1`, whether `%repo` names it or `FLEXO_SYSMLV2_URL` does; and the token stays on the server it was given for: a redirect or a next-page link to another host, another port or plaintext is refused, not followed with the token | | `%projects` | List the repository's projects, ` ()` one per line, every page of them; `no projects` for an empty repository | | `%publish [-d] [--project=] [--branch=] ` | Publish the elements rooted in `` — a qualified name resolved in the session — as SysML v2 API JSON: when no project is named after it (or after `--project`) a project is created and its first commit made, otherwise a commit on the branch `--branch` names, or the default branch, holding what differs from the branch head under that root — elements under other roots are left in place, and elements are deleted only from a branch the session loaded or published: the commit id and the counts of elements created, updated and deleted are reported, with what was left in place, `nothing to publish` when none differ. An option given twice is a usage error. `-d` sends every derived property the exporter computes; without it the ones the reader recomputes are left out. A project with the same name twice is refused naming both ids | | `Tab` | Complete meta commands, symbol names (after `%print`, `%instantiate`, `%features` …; a name that needs quoting is offered in quotes, `Q::'the ra` completing to `Q::'the rack'`), object references where a command takes one (`#` offers the ids there are; `car.` offers the object-holding features of `car` — the same ones a path may pass through — a multi-valued one as `car.wheels[1]`, `car.wheels[2]` …; completing reads and materializes nothing, so a part no command has reached yet is offered by type, and only the elements reading it would hold: those the features subsetting it contribute, then anonymous ones up to its lower bound — so an optional part (`spare : Wheel[0..1]`) or an abstract one, which hold only what subsets them, is offered only once something does), the form after `%render ` and the palette after `%render dot`, file paths after `%load` and `%save`, and the flags of `%load` and `%publish` with the repository's project names after `--name=`, `--id=` and `--project=` and after `%load` | diff --git a/internal/frontend/repl/replext/repository/repository.go b/internal/frontend/repl/replext/repository/repository.go index 6f26aa9e1a..2529b6392b 100644 --- a/internal/frontend/repl/replext/repository/repository.go +++ b/internal/frontend/repl/replext/repository/repository.go @@ -92,6 +92,23 @@ func findProject(ctx context.Context, c *sysmlapi.Client, id, name string) (sysm return sysmlapi.Project{}, &modelsync.AmbiguousNameError{Name: name, IDs: ids} } +// publishTarget is the project a publish by name means: the one the session +// tracks under that name while the server still has it by that name, else +// whatever the name resolves to now — a renamed or deleted one is no match. +func publishTarget(ctx context.Context, c *sysmlapi.Client, id, name string) (sysmlapi.Project, error) { + if id != "" { + project, err := findProject(ctx, c, id, "") + var missing *NotFoundError + switch { + case err == nil && project.Name == name: + return project, nil + case err != nil && !errors.As(err, &missing): + return sysmlapi.Project{}, err + } + } + return findProject(ctx, c, "", name) +} + // findBranch resolves a branch by name or id, or the project's default branch // when none is named. func findBranch(ctx context.Context, c *sysmlapi.Client, project sysmlapi.Project, nameOrID string) (sysmlapi.Branch, error) { @@ -195,7 +212,13 @@ func (repository) Publish(ctx context.Context, base string, req replext.PublishR result := &replext.PublishResult{} // State from another server is no history of this one, whatever ids match. known := req.State != nil && req.State.Base == base - project, err := findProject(ctx, c, "", name) + // The project the session tracks under this name is the one meant, by id, + // so a namesake elsewhere on the server does not make the name ambiguous. + id := "" + if known && req.State.ProjectName == name { + id = req.State.ProjectID + } + project, err := publishTarget(ctx, c, id, name) var missing *NotFoundError switch { case errors.As(err, &missing): diff --git a/internal/frontend/repl/repository.go b/internal/frontend/repl/repository.go index 5d267307f5..65db26fa87 100644 --- a/internal/frontend/repl/repository.go +++ b/internal/frontend/repl/repository.go @@ -30,12 +30,17 @@ const ( const completionTimeout = 2 * time.Second // repoBase is the base URL the repository commands address: the one %repo set, -// else the environment's. -func (s *Session) repoBase() string { +// else the environment's, held to the same transport rule %repo applies to a +// URL it is given, so a token never leaves on a URL that was never checked. +func (s *Session) repoBase() (string, error) { if s.repoURL != "" { - return s.repoURL + return s.repoURL, nil } - return replext.Repo().DefaultURL() + base := replext.Repo().DefaultURL() + if err := replext.Repo().CheckURL(base); err != nil { + return "", err + } + return base, nil } func (s *Session) metaRepositoryCommand(fields []string, _ string) (metaResult, bool) { @@ -65,7 +70,11 @@ func usageError(lines ...string) metaResult { func (s *Session) doRepo(args []string) metaResult { switch len(args) { case 0: - return metaOut([]string{"API base path: " + s.repoBase()}, false, nil) + base, err := s.repoBase() + if err != nil { + return metaOut(nil, false, err) + } + return metaOut([]string{"API base path: " + base}, false, nil) case 1: base := strings.TrimRight(nameText(args[0]), "/") if err := replext.Repo().CheckURL(base); err != nil { @@ -81,7 +90,11 @@ func (s *Session) doProjects(args []string) metaResult { if len(args) != 0 { return usageError(usageProjects) } - projects, err := replext.Repo().Projects(s.command.context(), s.repoBase()) + base, err := s.repoBase() + if err != nil { + return metaOut(nil, false, err) + } + projects, err := replext.Repo().Projects(s.command.context(), base) if err != nil { return metaOut(nil, false, err) } @@ -178,7 +191,11 @@ func (s *Session) doLoadRepository(args []string) metaResult { if req.Name != "" && req.ProjectID != "" { return usageError(usageLoadRepo, "name the project once: by --id, by --name or by itself") } - loaded, err := replext.Repo().Load(s.command.context(), s.repoBase(), req) + base, err := s.repoBase() + if err != nil { + return metaOut(nil, false, err) + } + loaded, err := replext.Repo().Load(s.command.context(), base, req) if err != nil { return metaOut(nil, false, err) } @@ -243,7 +260,11 @@ func (s *Session) doPublish(args []string) metaResult { req.Root = fqn req.Source = []byte(s.text()) req.State = s.repoState - published, err := replext.Repo().Publish(s.command.context(), s.repoBase(), req) + base, err := s.repoBase() + if err != nil { + return metaOut(nil, false, err) + } + published, err := replext.Repo().Publish(s.command.context(), base, req) if err != nil { return metaOut(nil, false, err) } @@ -305,9 +326,13 @@ func (s *Session) projectIDs() []string { } func (s *Session) projectField(field func(replext.ProjectInfo) string) []string { + base, err := s.repoBase() + if err != nil { + return nil + } ctx, cancel := context.WithTimeout(context.Background(), completionTimeout) defer cancel() - projects, err := replext.Repo().Projects(ctx, s.repoBase()) + projects, err := replext.Repo().Projects(ctx, base) if err != nil { return nil } diff --git a/internal/frontend/repl/repository_test.go b/internal/frontend/repl/repository_test.go index 10846841a5..a7b947747d 100644 --- a/internal/frontend/repl/repository_test.go +++ b/internal/frontend/repl/repository_test.go @@ -503,3 +503,108 @@ func TestRepositoryOptionsAreGivenOnce(t *testing.T) { } } } + +// The environment's URL is held to the rule %repo holds a URL it is given to: +// a plaintext server off this machine gets no request, so no token in the clear. +func TestDefaultURLIsHeldToThePlaintextRule(t *testing.T) { + if replext.Repo() == nil { + t.Fatal("no repository extension is linked") + } + t.Setenv("FLEXO_SYSMLV2_URL", "http://models.example.com/api") + t.Setenv("FLEXO_INTEROP_TOKEN", "secret") + t.Setenv("FLEXO_ALLOW_PLAIN_HTTP", "") + s := NewSession() + s.Submit(vehicles) + for _, line := range []string{"%repo", "%projects", "%load --id=project-0001", "%publish Vehicles"} { + err := metaErr(t, s, line) + if !strings.Contains(err.Error(), "FLEXO_ALLOW_PLAIN_HTTP") || !strings.Contains(err.Error(), "http://models.example.com/api") { + t.Errorf("%s: the environment's plaintext URL was taken: %v", line, err) + } + if strings.Contains(err.Error(), "secret") { + t.Errorf("%s: the refusal names the token: %v", line, err) + } + if strings.Contains(err.Error(), "did not answer") { + t.Errorf("%s: a request went out: %v", line, err) + } + } + if got := s.Complete("%load Veh", len("%load Veh")).Candidates; len(got) != 0 { + t.Errorf("completion asked the refused server: %q", got) + } + + if got := joined(runMeta(t, s, "%repo http://127.0.0.1:1/api")); got != "API base path: http://127.0.0.1:1/api" { + t.Errorf("%%repo = %q", got) + } + if err := metaErr(t, s, "%projects"); !strings.Contains(err.Error(), "did not answer") { + t.Errorf("a loopback URL set by %%repo was still refused: %v", err) + } + + t.Setenv("FLEXO_ALLOW_PLAIN_HTTP", "1") + if got := joined(runMeta(t, NewSession(), "%repo")); got != "API base path: http://models.example.com/api" { + t.Errorf("with the opt-in, %%repo = %q", got) + } +} + +// A project the session loaded by id is the one a publish by its name means, +// even when another project on the server has the same name. +func TestPublishByNameUpdatesTheLoadedNamesake(t *testing.T) { + s, api := repoSession(t) + s.Submit(vehicles) + runMeta(t, s, "%publish Vehicles") + namesake := api.addProject("Vehicles") + held := api.elementCount("project-0001", "branch-0002") + + untracked := NewSession() + untracked.Submit(vehicles) + if err := metaErr(t, untracked, "%publish Vehicles"); !strings.Contains(err.Error(), "project-0001") || !strings.Contains(err.Error(), namesake.id) { + t.Errorf("without a loaded project, the shared name is not refused naming both: %v", err) + } + + fresh := NewSession() + runMeta(t, fresh, "%load --id=project-0001") + fresh.Submit("package Vehicles { part def Wheel { attribute radius : ScalarValues::Real; } part def Car { part wheels : Wheel[4]; } part def Bus; }") + out := runMeta(t, fresh, "%publish Vehicles") + if len(out) != 1 || !strings.Contains(out[0], "of Vehicles (project-0001)") { + t.Fatalf("publish by name after loading by id:\n%s", joined(out)) + } + if now := api.elementCount("project-0001", "branch-0002"); now <= held { + t.Errorf("the loaded project holds %d elements, had %d; Bus was not added", now, held) + } + if api.elementCount(namesake.id, namesake.defaults) != 0 || len(api.order) != 2 { + t.Errorf("the namesake received elements or a project was created: %d projects", len(api.order)) + } + fresh.Submit("package Spare { part def Boat; }") + out = runMeta(t, fresh, "%publish --project=Vehicles Spare") + if len(out) < 1 || !strings.Contains(out[0], "of Vehicles (project-0001)") { + t.Errorf("--project naming the tracked project's name:\n%s", joined(out)) + } + if len(api.order) != 2 { + t.Errorf("--project=Vehicles created a project: %d projects", len(api.order)) + } + + // Renamed on the server, the tracked project no longer answers to the name: + // the namesake, now alone under it, is the one published to. + api.mu.Lock() + api.projects["project-0001"].name = "Fleet" + api.mu.Unlock() + out = runMeta(t, fresh, "%publish Vehicles") + if len(out) != 1 || !strings.Contains(out[0], "of Vehicles ("+namesake.id+")") { + t.Fatalf("publish by a name the tracked project lost:\n%s", joined(out)) + } + if api.elementCount(namesake.id, namesake.defaults) == 0 || len(api.order) != 2 { + t.Errorf("the namesake holds nothing or a project was created: %d projects", len(api.order)) + } + + // Deleted on the server, likewise: no project is created over the namesake. + api.mu.Lock() + delete(api.projects, "project-0001") + api.order = api.order[1:] + api.mu.Unlock() + fresh.Submit("package Vehicles { part def Wheel { attribute radius : ScalarValues::Real; } part def Car { part wheels : Wheel[4]; } part def Bus; part def Van; }") + out = runMeta(t, fresh, "%publish Vehicles") + if len(out) != 1 || !strings.Contains(out[0], "of Vehicles ("+namesake.id+")") { + t.Fatalf("publish by name after the tracked project was deleted:\n%s", joined(out)) + } + if len(api.order) != 1 { + t.Errorf("a project was created over the namesake: %d projects", len(api.order)) + } +} diff --git a/internal/translate/interop/sysmlapi/client.go b/internal/translate/interop/sysmlapi/client.go index c714ec66c9..accea20d20 100644 --- a/internal/translate/interop/sysmlapi/client.go +++ b/internal/translate/interop/sysmlapi/client.go @@ -124,18 +124,28 @@ func (c *Client) checkRedirect(req *http.Request, via []*http.Request) error { if len(via) >= maxRedirects { return fmt.Errorf("stopped after %d redirects", maxRedirects) } + from := via[len(via)-1].URL + if err := c.tokenStays(from, req.URL); err != nil { + return fmt.Errorf("redirect from %s refused: %w", from, err) + } + return nil +} + +// tokenStays is the rule a request's successor — a redirect's target, a linked +// next page — is held to when there is a bearer token to carry: no plaintext, +// no leaving https once there, and no other server than the configured one. +func (c *Client) tokenStays(from, to *url.URL) error { if c.cfg.Token == "" { return nil } - from := via[len(via)-1].URL - if err := CheckURL(req.URL.String()); err != nil { - return fmt.Errorf("redirect from %s refused: %w", from, err) + if err := CheckURL(to.String()); err != nil { + return err } - if from.Scheme == "https" && req.URL.Scheme != "https" { - return fmt.Errorf("redirect from %s to %s refused: the token stays on https", from, req.URL.Scheme) + if from.Scheme == "https" && to.Scheme != "https" { + return fmt.Errorf("%s: the token stays on https", to.Scheme) } - if base, err := url.Parse(c.cfg.BaseURL); err == nil && !sameServer(req.URL, base) { - return fmt.Errorf("redirect from %s to %s refused: the token is for %s only", from, req.URL.Host, base.Host) + if base, err := url.Parse(c.cfg.BaseURL); err == nil && !sameServer(to, base) { + return fmt.Errorf("%s: the token is for %s only", to.Host, base.Host) } return nil } @@ -377,7 +387,11 @@ func (c *Client) paged(ctx context.Context, target string, what string, each fun } // A server linking its pages is followed to the end whatever each // page holds; without a link, a short page is the last. - if linked := nextLink(header, next); linked != "" && linked != next { + linked, err := c.nextPage(header, next) + if err != nil { + return err + } + if linked != "" && linked != next { next = linked continue } @@ -404,6 +418,28 @@ func withPaging(target, after string) string { return out } +// nextPage is the page a response links as next, held to the rule a redirect +// is: a link to another server, or into the clear, is refused rather than +// followed with the token. +func (c *Client) nextPage(header http.Header, requested string) (string, error) { + linked := nextLink(header, requested) + if linked == "" { + return "", nil + } + from, err := url.Parse(requested) + if err != nil { + return "", err + } + to, err := url.Parse(linked) + if err != nil { + return "", err + } + if err := c.tokenStays(from, to); err != nil { + return "", fmt.Errorf("next page linked from %s refused: %w", from, err) + } + return linked, nil +} + // nextLink is the rel="next" target of a Link header, resolved against the // request's URL; empty when the response links no next page. func nextLink(header http.Header, requested string) string { @@ -569,7 +605,10 @@ func (c *Client) Elements(ctx context.Context, project, commit string, size int) if len(page) == 0 || listing.Responses > maxPages { return listing, nil } - linked := nextLink(header, target) + linked, err := c.nextPage(header, target) + if err != nil { + return listing, err + } switch { case linked != "" && linked != target: // A server linking its pages names the continuation itself, diff --git a/internal/translate/interop/sysmlapi/client_test.go b/internal/translate/interop/sysmlapi/client_test.go index fe571ce771..083030c9a3 100644 --- a/internal/translate/interop/sysmlapi/client_test.go +++ b/internal/translate/interop/sysmlapi/client_test.go @@ -9,6 +9,7 @@ import ( "net/url" "strconv" "strings" + "sync/atomic" "testing" ) @@ -120,3 +121,46 @@ func TestRedirectsKeepTheTokenOffPlaintext(t *testing.T) { t.Errorf("without a token, a redirect elsewhere was refused: %v", err) } } + +// TestLinkedPagesKeepTheTokenOnTheServer: a next-page link is held to the rule +// a redirect is, so a server cannot have the token sent on to another host. +func TestLinkedPagesKeepTheTokenOnTheServer(t *testing.T) { + t.Setenv(EnvPlainHTTP, "1") + var elsewhere int32 + other := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&elsewhere, 1) + _ = json.NewEncoder(w).Encode([]map[string]any{{"@id": "p2", "@type": "Project", "name": "Two"}}) + })) + defer other.Close() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Link", fmt.Sprintf(`<%s%s>; rel="next"`, other.URL, r.URL.RequestURI())) + _ = json.NewEncoder(w).Encode([]map[string]any{{"@id": "p1", "@type": "Project", "name": "One"}}) + })) + defer server.Close() + otherHost, serverHost := strings.TrimPrefix(other.URL, "http://"), strings.TrimPrefix(server.URL, "http://") + + c := New(Config{BaseURL: server.URL, Token: "secret"}) + for name, list := range map[string]func() error{ + "projects": func() error { _, err := c.Projects(context.Background()); return err }, + "elements": func() error { _, err := c.Elements(context.Background(), "p", "c", 1); return err }, + } { + err := list() + if err == nil { + t.Fatalf("%s: a page on another server was read with the token", name) + } + if !strings.Contains(err.Error(), otherHost) || !strings.Contains(err.Error(), serverHost) { + t.Errorf("%s: the refusal names neither %s nor %s: %v", name, otherHost, serverHost, err) + } + if strings.Contains(err.Error(), "secret") { + t.Errorf("%s: the refusal names the token: %v", name, err) + } + } + if n := atomic.LoadInt32(&elsewhere); n != 0 { + t.Errorf("the other server was asked %d time(s)", n) + } + + tokenless := New(Config{BaseURL: server.URL}) + if projects, err := tokenless.Projects(context.Background()); err != nil || len(projects) != 2 { + t.Errorf("without a token, the linked page was not followed: %d projects, %v", len(projects), err) + } +} diff --git a/internal/translate/interop/sysmlapi/repository.go b/internal/translate/interop/sysmlapi/repository.go index 3800c86d11..19112c00f3 100644 --- a/internal/translate/interop/sysmlapi/repository.go +++ b/internal/translate/interop/sysmlapi/repository.go @@ -15,13 +15,15 @@ const elementsPageSize = 500 // Repository is one project branch as a sync's repository: read as the graph // its elements spell, written through the commit path so every element keeps -// its id. It remembers the head its last read stood at and refuses to commit -// past a head that has since moved. +// its id. It remembers the head its last read stood at — empty for a branch +// read before its first commit — and refuses to commit past a head that has +// since moved. type Repository struct { client *Client project string branch string seen string + read bool // whether seen records a head this repository read or wrote } // Repository addresses one project branch for a sync. @@ -77,14 +79,14 @@ func (r *Repository) Graph(ctx context.Context) (*rdf.Graph, error) { return nil, err } if head == "" { - r.seen = "" + r.seen, r.read = "", true return rdf.NewGraph(), nil } graph, err := r.GraphAt(ctx, head) if err != nil { return nil, err } - r.seen = head + r.seen, r.read = head, true return graph, nil } @@ -112,14 +114,15 @@ func ElementsGraph(elements []Element) (*rdf.Graph, error) { } // Commit writes one batch as one SysML v2 commit. The API takes no -// precondition, so the head is re-read first and a moved one refuses the write -// as a StaleBranchError. +// precondition, so once a head is known — read by Graph, resumed or written — +// it is re-read first and a moved one refuses the write as a StaleBranchError; +// a branch read without a head has to be still without one. func (r *Repository) Commit(ctx context.Context, changes []reposync.ElementChange, message string) (string, error) { body, err := CommitRequest(changes, message) if err != nil { return "", err } - if r.seen != "" { + if r.read || r.seen != "" { head, err := r.Head(ctx) if err != nil { return "", err @@ -132,6 +135,6 @@ func (r *Repository) Commit(ctx context.Context, changes []reposync.ElementChang if err != nil { return "", err } - r.seen = commit.ID + r.seen, r.read = commit.ID, true return commit.ID, nil } diff --git a/internal/translate/interop/sysmlapi/repository_test.go b/internal/translate/interop/sysmlapi/repository_test.go new file mode 100644 index 0000000000..766517bcf8 --- /dev/null +++ b/internal/translate/interop/sysmlapi/repository_test.go @@ -0,0 +1,72 @@ +package sysmlapi + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/translate/interop/reposync" + "github.com/Open-MBEE/OpenSysML/internal/translate/rdf" +) + +// TestCommitRefusesAHeadThatAppearedSinceTheRead: a branch read before its +// first commit is still a head the write is held to, so a first commit another +// writer made in between refuses the change set computed against nothing. +func TestCommitRefusesAHeadThatAppearedSinceTheRead(t *testing.T) { + head, posts := "", 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/projects/p/branches/b": + branch := map[string]any{"@id": "b", "@type": "Branch", "name": "main", "head": nil} + if head != "" { + branch["head"] = map[string]any{"@id": head, "@type": "Commit"} + } + _ = json.NewEncoder(w).Encode(branch) + case r.Method == http.MethodPost && r.URL.Path == "/projects/p/commits": + posts++ + _ = json.NewEncoder(w).Encode(map[string]any{"@id": "c2", "@type": "Commit"}) + default: + http.Error(w, r.Method+" "+r.URL.Path, http.StatusNotFound) + } + })) + defer server.Close() + + repo := New(Config{BaseURL: server.URL}).Repository("p", "b") + graph, err := repo.Graph(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(graph.Subjects()) != 0 || repo.Seen() != "" { + t.Fatalf("a branch without a head read as %d subjects at commit %q", len(graph.Subjects()), repo.Seen()) + } + changes := []reposync.ElementChange{{Kind: reposync.KindCreate, ID: "X1", Content: []rdf.Triple{ + triple(rdf.Element+"X1", rdf.RDFType, rdf.IRI(rdf.SysML+"Package")), + }}} + + head = "c1" + _, err = repo.Commit(context.Background(), changes, "first") + var stale *StaleBranchError + if !errors.As(err, &stale) { + t.Fatalf("committing onto a head that appeared since the read: %v, want a StaleBranchError", err) + } + if stale.Seen != "" || stale.Head != "c1" { + t.Errorf("the refusal says the head moved from %q to %q, want from none to c1", stale.Seen, stale.Head) + } + if posts != 0 { + t.Errorf("%d commit(s) were posted onto the moved branch", posts) + } + + head = "" + if commit, err := repo.Commit(context.Background(), changes, "first"); err != nil || commit != "c2" { + t.Errorf("committing onto a branch still without a head: %q, %v", commit, err) + } + if posts != 1 { + t.Errorf("%d commit(s) posted, want 1", posts) + } + if repo.Seen() != "c2" { + t.Errorf("after the write the repository stands at %q, want c2", repo.Seen()) + } +}