package adminapi import ( "net/http" "os" "path/filepath" "testing" "github.com/iceBear67/simplepages/api" "github.com/iceBear67/simplepages/internal/deploy" ) // readyDeployment runs a whole deployment through create, manifest, upload and // finalize, so an activation test starts from the only state activation accepts. func (e *env) readyDeployment(t *testing.T, token, project string, contents map[string]string) api.Deployment { t.Helper() dep := e.startDeployment(t, token, project) files := make([]api.FileEntry, 0, len(contents)) for path, content := range contents { files = append(files, entry(path, content)) } status, body := e.do(t, http.MethodPost, api.PathManifest(project, dep.ID), token, api.ManifestRequest{Files: files}) var mr api.ManifestResponse mustJSON(t, status, http.StatusOK, body, &mr) for _, content := range contents { if status, body := e.putBlob(t, token, content); status != http.StatusCreated && status != http.StatusOK { t.Fatalf("upload: status = %d; body: %s", status, body) } } status, body = e.do(t, http.MethodPost, api.PathFinalize(project, dep.ID), token, nil) var out api.Deployment mustJSON(t, status, http.StatusOK, body, &out) return out } // symlink reads $WEBROOT/~project, failing if it is not a link. func (e *env) symlink(t *testing.T, project string) string { t.Helper() target, err := os.Readlink(filepath.Join(e.webrootDir, "~"+project)) if err != nil { t.Fatalf("readlink ~%s: %v", project, err) } return target } // TestActivate covers the endpoint the whole product turns on: what it answers, // what it publishes into the serving registry, and what it leaves on disk. func TestActivate(t *testing.T) { e := newEnv(t) e.createProject(t, "demo") token := e.mintProject(t, e.projectID(t, "demo"), "ci") pid := e.projectID(t, "demo") // Nothing is served before the first activation. sp, ok := e.sites.Lookup("demo") if !ok { t.Fatal("creating a project did not register it") } if sp.Active() != nil { t.Fatal("a project with no deployment is serving something") } first := e.readyDeployment(t, token, "demo", map[string]string{ "index.html": "

v1

", "assets/app.js": "console.log(1)", }) status, body := e.do(t, http.MethodPost, api.PathActivate("demo", first.ID), token, nil) var got api.Deployment mustJSON(t, status, http.StatusOK, body, &got) if got.ID != first.ID || !got.Active || got.State != api.StateReady { t.Fatalf("activated = %+v", got) } if got.ActivatedAt == nil { t.Error("activated_at is missing from the response") } if got.URL != "" { t.Errorf("url = %q, want it omitted when no base URL is configured", got.URL) } // The registry is what the site handler reads, so this is the assertion that // the deployment is actually being served and not merely recorded. d := sp.Active() if d == nil { t.Fatal("activation did not publish into the registry") } if d.ID != first.ID || d.FileCount != 2 { t.Fatalf("published snapshot = %+v", d) } if _, ok := d.Lookup("assets/app.js"); !ok { t.Error("the published snapshot does not know a file the manifest declared") } wantDir := deploy.DeploymentDir(e.deployDir, pid, first.ID) if got := e.symlink(t, "demo"); got != wantDir { t.Errorf("~demo -> %q, want %q", got, wantDir) } // The project listing now reports what is being served, without a query per // project. status, body = e.do(t, http.MethodGet, api.PathProject("demo"), token, nil) var p api.Project mustJSON(t, status, http.StatusOK, body, &p) if p.ActiveDeployment == nil || p.ActiveDeployment.ID != first.ID { t.Fatalf("active_deployment = %+v", p.ActiveDeployment) } if !p.ActiveDeployment.Active || p.ActiveDeployment.State != api.StateReady { t.Errorf("active_deployment = %+v", p.ActiveDeployment) } // --- a second deployment takes over second := e.readyDeployment(t, token, "demo", map[string]string{ "index.html": "

v2

", "assets/app.js": "console.log(1)", // unchanged, so it shares a blob }) status, body = e.do(t, http.MethodPost, api.PathActivate("demo", second.ID), token, nil) mustJSON(t, status, http.StatusOK, body, &got) if got.ID != second.ID || !got.Active { t.Fatalf("second activation = %+v", got) } if d := sp.Active(); d == nil || d.ID != second.ID { t.Fatalf("the registry still serves %v", d) } if got := e.symlink(t, "demo"); got != deploy.DeploymentDir(e.deployDir, pid, second.ID) { t.Errorf("~demo -> %q, want the second deployment", got) } // --- rollback is the same endpoint on an older deployment status, body = e.do(t, http.MethodPost, api.PathActivate("demo", first.ID), token, nil) mustJSON(t, status, http.StatusOK, body, &got) if got.ID != first.ID || !got.Active { t.Fatalf("rollback = %+v", got) } if d := sp.Active(); d == nil || d.ID != first.ID { t.Fatalf("the registry did not roll back: %v", d) } if got := e.symlink(t, "demo"); got != wantDir { t.Errorf("~demo -> %q, want the first deployment again", got) } // Re-activating what is already active is a no-op that still answers. status, body = e.do(t, http.MethodPost, api.PathActivate("demo", first.ID), token, nil) mustJSON(t, status, http.StatusOK, body, &got) if !got.Active { t.Errorf("re-activation = %+v", got) } } func TestActivateReportsTheSiteURL(t *testing.T) { e := newEnv(t) e.server.BaseURL = "https://pages.example.com" e.createProject(t, "demo") token := e.mintProject(t, e.projectID(t, "demo"), "ci") dep := e.readyDeployment(t, token, "demo", map[string]string{"index.html": "

hi

"}) status, body := e.do(t, http.MethodPost, api.PathActivate("demo", dep.ID), token, nil) var got api.Deployment mustJSON(t, status, http.StatusOK, body, &got) if want := api.SiteURL(e.server.BaseURL, "demo"); got.URL != want { t.Errorf("url = %q, want %q", got.URL, want) } } // Activation is the one operation that changes what the world sees, so every // deployment that is not finished must be refused — and refused without // disturbing whatever is being served. func TestActivateRequiresAReadyDeployment(t *testing.T) { e := newEnv(t) e.createProject(t, "demo") token := e.mintProject(t, e.projectID(t, "demo"), "ci") serving := e.readyDeployment(t, token, "demo", map[string]string{"index.html": "

v1

"}) status, body := e.do(t, http.MethodPost, api.PathActivate("demo", serving.ID), token, nil) mustJSON(t, status, http.StatusOK, body, nil) t.Run("pending", func(t *testing.T) { dep := e.startDeployment(t, token, "demo") status, body := e.do(t, http.MethodPost, api.PathActivate("demo", dep.ID), token, nil) if status != http.StatusConflict { t.Fatalf("status = %d, want 409; body: %s", status, body) } if code := errCode(t, body); code != api.CodeDeploymentNotReady { t.Errorf("code = %q, want %q", code, api.CodeDeploymentNotReady) } }) t.Run("uploading", func(t *testing.T) { dep := e.startDeployment(t, token, "demo") status, body := e.do(t, http.MethodPost, api.PathManifest("demo", dep.ID), token, api.ManifestRequest{Files: []api.FileEntry{entry("index.html", "never uploaded")}}) mustJSON(t, status, http.StatusOK, body, nil) status, body = e.do(t, http.MethodPost, api.PathActivate("demo", dep.ID), token, nil) if status != http.StatusConflict { t.Fatalf("status = %d, want 409; body: %s", status, body) } }) t.Run("unknown", func(t *testing.T) { status, body := e.do(t, http.MethodPost, api.PathActivate("demo", "dpl_ffffffffffffffff"), token, nil) if status != http.StatusNotFound { t.Fatalf("status = %d, want 404; body: %s", status, body) } if code := errCode(t, body); code != api.CodeNotFound { t.Errorf("code = %q, want %q", code, api.CodeNotFound) } }) // Every refusal above left the site alone. sp, _ := e.sites.Lookup("demo") if d := sp.Active(); d == nil || d.ID != serving.ID { t.Errorf("a refused activation changed what is served: %v", d) } } // The security property: naming another project's deployment must not activate // it, whether the caller routes through their own project or the victim's. func TestActivateIsProjectScoped(t *testing.T) { e := newEnv(t) e.createProject(t, "victim") e.createProject(t, "attacker") victimToken := e.mintProject(t, e.projectID(t, "victim"), "ci") attackerToken := e.mintProject(t, e.projectID(t, "attacker"), "ci") target := e.readyDeployment(t, victimToken, "victim", map[string]string{"index.html": "

v1

"}) newer := e.readyDeployment(t, victimToken, "victim", map[string]string{"index.html": "

v2

"}) status, body := e.do(t, http.MethodPost, api.PathActivate("victim", newer.ID), victimToken, nil) mustJSON(t, status, http.StatusOK, body, nil) t.Run("through the victim's project", func(t *testing.T) { status, body := e.do(t, http.MethodPost, api.PathActivate("victim", target.ID), attackerToken, nil) if status != http.StatusForbidden { t.Fatalf("status = %d, want 403; body: %s", status, body) } }) t.Run("through the attacker's own project", func(t *testing.T) { status, body := e.do(t, http.MethodPost, api.PathActivate("attacker", target.ID), attackerToken, nil) if status != http.StatusNotFound { t.Fatalf("status = %d, want 404; body: %s", status, body) } }) sp, _ := e.sites.Lookup("victim") if d := sp.Active(); d == nil || d.ID != newer.ID { t.Errorf("the victim is now serving %v", d) } if _, ok := e.sites.Lookup("attacker"); !ok { t.Fatal("the attacker's project vanished") } if sp, _ := e.sites.Lookup("attacker"); sp.Active() != nil { t.Error("the attacker ended up serving the victim's deployment") } } func TestActivateRejectsABodyAndWrongMethods(t *testing.T) { e := newEnv(t) e.createProject(t, "demo") token := e.mintProject(t, e.projectID(t, "demo"), "ci") dep := e.readyDeployment(t, token, "demo", map[string]string{"index.html": "

hi

"}) status, body := e.do(t, http.MethodPost, api.PathActivate("demo", dep.ID), token, map[string]string{"unexpected": "field"}) if status != http.StatusBadRequest { t.Fatalf("status = %d, want 400; body: %s", status, body) } for _, method := range []string{http.MethodGet, http.MethodPut, http.MethodDelete} { resp := e.doResp(t, method, api.PathActivate("demo", dep.ID), token, nil) if resp.StatusCode != http.StatusMethodNotAllowed { t.Errorf("%s: status = %d, want 405", method, resp.StatusCode) } if allow := resp.Header.Get("Allow"); allow != http.MethodPost { t.Errorf("%s: Allow = %q, want POST", method, allow) } } if status, body := e.do(t, http.MethodPost, api.PathActivate("demo", dep.ID), "", nil); status != http.StatusUnauthorized { t.Errorf("unauthenticated: status = %d, want 401; body: %s", status, body) } } // Deleting a project takes its symlink with it: the deployment tree is about to // be GC'd, and a link left pointing at it would dangle. func TestDeleteProjectUnpointsTheWebroot(t *testing.T) { e := newEnv(t) e.createProject(t, "demo") token := e.mintProject(t, e.projectID(t, "demo"), "ci") dep := e.readyDeployment(t, token, "demo", map[string]string{"index.html": "

hi

"}) status, body := e.do(t, http.MethodPost, api.PathActivate("demo", dep.ID), token, nil) mustJSON(t, status, http.StatusOK, body, nil) e.symlink(t, "demo") status, body = e.do(t, http.MethodDelete, api.PathProject("demo"), e.adminToken, nil) if status != http.StatusNoContent { t.Fatalf("delete: status = %d; body: %s", status, body) } if _, err := os.Lstat(filepath.Join(e.webrootDir, "~demo")); err == nil { t.Error("the symlink outlived the project") } if _, ok := e.sites.Lookup("demo"); ok { t.Error("the deleted project is still in the serving registry") } }