308 lines
11 KiB
Go
308 lines
11 KiB
Go
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": "<h1>v1</h1>",
|
|
"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": "<h1>v2</h1>",
|
|
"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": "<h1>hi</h1>"})
|
|
|
|
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": "<h1>v1</h1>"})
|
|
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": "<h1>v1</h1>"})
|
|
newer := e.readyDeployment(t, victimToken, "victim", map[string]string{"index.html": "<h1>v2</h1>"})
|
|
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": "<h1>hi</h1>"})
|
|
|
|
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": "<h1>hi</h1>"})
|
|
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")
|
|
}
|
|
}
|