package bm_test import ( "bytes" "encoding/json" "io" "mime/multipart" "net/http" "net/http/httptest" "strconv" "strings" "testing" userclasses "git.golem15.com/golem15/sm-user-plugin/classes" "git.golem15.com/golem15/summercms/modules/surf" "gocloud.dev/blob" _ "gocloud.dev/blob/memblob" ) func TestManagementAPIUsesSharedTokenAndCurrentUserPermissions(t *testing.T) { app, plugins, db := migrated(t) var userID uint if err := db.Raw(` INSERT INTO users (email, password, must_change_password) VALUES ('bm-shared-token@example.test', '', false) RETURNING id`).Scan(&userID).Error; err != nil { t.Fatal(err) } var groupID uint if err := db.Raw(` INSERT INTO user_groups (name, code, permissions, created_at, updated_at) VALUES ('BM API', 'bm-api', '{"jz.bm.access_courses":1}', NOW(), NOW()) RETURNING id`).Scan(&groupID).Error; err != nil { t.Fatal(err) } if err := db.Exec(`INSERT INTO users_groups (user_id, user_group_id) VALUES (?, ?)`, userID, groupID).Error; err != nil { t.Fatal(err) } secret, token, err := userclasses.MintAPIToken("usr_", userID, "BM integration", []string{"read"}, nil, nil) if err != nil { t.Fatal(err) } if err := db.Create(token).Error; err != nil { t.Fatal(err) } handler, err := surf.Assemble(app, plugins) if err != nil { t.Fatal(err) } request := func(path string) *httptest.ResponseRecorder { req := httptest.NewRequest(http.MethodGet, path, nil) req.Header.Set("Authorization", "Bearer "+secret) rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) return rec } if rec := request("/api/v1/bm/courses"); rec.Code != http.StatusOK { t.Fatalf("permitted courses request = %d %s", rec.Code, rec.Body.String()) } if rec := request("/api/v1/bm/materials"); rec.Code != http.StatusForbidden { t.Fatalf("ungranted materials request = %d %s", rec.Code, rec.Body.String()) } if err := db.Model(&struct{}{}).Table("user_groups").Where("id = ?", groupID).Update("permissions", `{}`).Error; err != nil { t.Fatal(err) } if rec := request("/api/v1/bm/courses"); rec.Code != http.StatusForbidden || !strings.Contains(rec.Body.String(), "Forbidden") { t.Fatalf("request after permission revocation = %d %s", rec.Code, rec.Body.String()) } } // TestManagementMaterialsUpsertAndUpdate drives the material routes of the // management API with a write token: the upsert identity (title and course), // the update by id with a rename and a draft, the identity conflict, and // the 404s. func TestManagementMaterialsUpsertAndUpdate(t *testing.T) { app, plugins, db := migrated(t) var userID uint if err := db.Raw(` INSERT INTO users (email, password, must_change_password) VALUES ('bm-materials-token@example.test', '', false) RETURNING id`).Scan(&userID).Error; err != nil { t.Fatal(err) } var groupID uint if err := db.Raw(` INSERT INTO user_groups (name, code, permissions, created_at, updated_at) VALUES ('BM materials', 'bm-materials', '{"jz.bm.access_materials":1}', NOW(), NOW()) RETURNING id`).Scan(&groupID).Error; err != nil { t.Fatal(err) } if err := db.Exec(`INSERT INTO users_groups (user_id, user_group_id) VALUES (?, ?)`, userID, groupID).Error; err != nil { t.Fatal(err) } secret, token, err := userclasses.MintAPIToken("usr_", userID, "BM notes", []string{"read", "write"}, nil, nil) if err != nil { t.Fatal(err) } if err := db.Create(token).Error; err != nil { t.Fatal(err) } handler, err := surf.Assemble(app, plugins) if err != nil { t.Fatal(err) } send := func(method, path string, payload any) (*httptest.ResponseRecorder, map[string]any) { t.Helper() var rd io.Reader if payload != nil { raw, err := json.Marshal(payload) if err != nil { t.Fatal(err) } rd = bytes.NewReader(raw) } req := httptest.NewRequest(method, path, rd) req.Header.Set("Authorization", "Bearer "+secret) if payload != nil { req.Header.Set("Content-Type", "application/json") } rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) var out map[string]any if rec.Body.Len() > 0 { _ = json.Unmarshal(rec.Body.Bytes(), &out) } return rec, out } data := func(out map[string]any) map[string]any { t.Helper() d, _ := out["data"].(map[string]any) if d == nil { t.Fatalf("no data object in %v", out) } return d } idPath := func(id any) string { return "/api/v1/bm/materials/" + strconv.Itoa(int(id.(float64))) } note := map[string]any{"title": "Definicje globalizacji", "type": "notatka", "body": "# Robertson\n\n> single place", "tags": []string{"Globalizacja", "Zjazd 2"}} rec, out := send(http.MethodPost, "/api/v1/bm/materials", note) if rec.Code != http.StatusCreated || out["created"] != true { t.Fatalf("create = %d %s", rec.Code, rec.Body.String()) } first := data(out) if first["published_at"] == nil { t.Error("a new material without published_at is not published") } if tags, _ := first["tags"].([]any); len(tags) != 2 || tags[1].(map[string]any)["slug"] != "zjazd-2" { t.Errorf("tags = %v", first["tags"]) } rec, out = send(http.MethodPost, "/api/v1/bm/materials", note) if rec.Code != http.StatusOK || out["created"] != false || data(out)["id"] != first["id"] { t.Fatalf("repeated upsert = %d %s", rec.Code, rec.Body.String()) } rec, out = send(http.MethodGet, idPath(first["id"]), nil) if rec.Code != http.StatusOK || data(out)["title"] != "Definicje globalizacji" { t.Fatalf("show = %d %s", rec.Code, rec.Body.String()) } if files, ok := data(out)["files"].([]any); !ok || len(files) != 0 { t.Errorf("files = %v", data(out)["files"]) } renamed := map[string]any{"title": "Definicje globalizacji (zjazd 2)", "type": "notatka", "body": "zmienione", "published_at": nil} rec, out = send(http.MethodPut, idPath(first["id"]), renamed) if rec.Code != http.StatusOK { t.Fatalf("update = %d %s", rec.Code, rec.Body.String()) } updated := data(out) if updated["title"] != renamed["title"] || updated["body"] != "zmienione" || updated["published_at"] != nil { t.Errorf("updated = %v", updated) } if tags, _ := updated["tags"].([]any); len(tags) != 2 { t.Errorf("an update without tags changed them: %v", updated["tags"]) } rec, out = send(http.MethodPost, "/api/v1/bm/materials", note) if rec.Code != http.StatusCreated || data(out)["id"] == first["id"] { t.Fatalf("upsert after the rename = %d %s", rec.Code, rec.Body.String()) } second := data(out) rec, out = send(http.MethodPut, idPath(second["id"]), renamed) if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), `"title"`) { t.Fatalf("update onto a taken identity = %d %s", rec.Code, rec.Body.String()) } for _, method := range []string{http.MethodGet, http.MethodPut} { var payload any if method == http.MethodPut { payload = renamed } if rec, _ := send(method, "/api/v1/bm/materials/999999", payload); rec.Code != http.StatusNotFound { t.Errorf("%s of an unknown id = %d %s", method, rec.Code, rec.Body.String()) } } if rec, _ := send(http.MethodDelete, idPath(first["id"]), nil); rec.Code != http.StatusNoContent { t.Fatalf("delete = %d %s", rec.Code, rec.Body.String()) } if rec, _ := send(http.MethodGet, idPath(first["id"]), nil); rec.Code != http.StatusNotFound { t.Errorf("show after delete = %d", rec.Code) } } // TestManagementMaterialFiles uploads files to a material through the // management API, refuses a disallowed type, detaches one file and deletes // the material with the other, checking rows and blobs. func TestManagementMaterialFiles(t *testing.T) { app, plugins, db := migrated(t) bucket, err := blob.OpenBucket(t.Context(), "mem://") if err != nil { t.Fatal(err) } t.Cleanup(func() { bucket.Close() }) if err := app.Publish(bucket); err != nil { t.Fatal(err) } var userID uint if err := db.Raw(` INSERT INTO users (email, password, must_change_password) VALUES ('bm-files-token@example.test', '', false) RETURNING id`).Scan(&userID).Error; err != nil { t.Fatal(err) } var groupID uint if err := db.Raw(` INSERT INTO user_groups (name, code, permissions, created_at, updated_at) VALUES ('BM files', 'bm-files', '{"jz.bm.access_materials":1}', NOW(), NOW()) RETURNING id`).Scan(&groupID).Error; err != nil { t.Fatal(err) } if err := db.Exec(`INSERT INTO users_groups (user_id, user_group_id) VALUES (?, ?)`, userID, groupID).Error; err != nil { t.Fatal(err) } secret, token, err := userclasses.MintAPIToken("usr_", userID, "BM files", []string{"read", "write"}, nil, nil) if err != nil { t.Fatal(err) } if err := db.Create(token).Error; err != nil { t.Fatal(err) } handler, err := surf.Assemble(app, plugins) if err != nil { t.Fatal(err) } do := func(req *http.Request) (*httptest.ResponseRecorder, map[string]any) { t.Helper() req.Header.Set("Authorization", "Bearer "+secret) rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) var out map[string]any if rec.Body.Len() > 0 { _ = json.Unmarshal(rec.Body.Bytes(), &out) } return rec, out } upload := func(path string, files map[string][]byte) (*httptest.ResponseRecorder, map[string]any) { t.Helper() var buf bytes.Buffer mw := multipart.NewWriter(&buf) for name, content := range files { fw, err := mw.CreateFormFile("file", name) if err != nil { t.Fatal(err) } fw.Write(content) } mw.Close() req := httptest.NewRequest(http.MethodPost, path, &buf) req.Header.Set("Content-Type", mw.FormDataContentType()) return do(req) } countFiles := func() int64 { var n int64 if err := db.Table("system_files").Where("field = 'files'").Count(&n).Error; err != nil { t.Fatal(err) } return n } raw, _ := json.Marshal(map[string]any{"title": "Podręcznik", "type": "pdf"}) req := httptest.NewRequest(http.MethodPost, "/api/v1/bm/materials", bytes.NewReader(raw)) req.Header.Set("Content-Type", "application/json") rec, out := do(req) if rec.Code != http.StatusCreated { t.Fatalf("create = %d %s", rec.Code, rec.Body.String()) } id := strconv.Itoa(int(out["data"].(map[string]any)["id"].(float64))) base := "/api/v1/bm/materials/" + id pdf := []byte("%PDF-1.4\n1 0 obj << >> endobj\ntrailer << >>\n%%EOF\n") rec, out = upload(base+"/files", map[string][]byte{"wojna.pdf": pdf}) if rec.Code != http.StatusCreated { t.Fatalf("upload = %d %s", rec.Code, rec.Body.String()) } rec, out = upload(base+"/files", map[string][]byte{"plan.txt": []byte("plan zajęć\n")}) if rec.Code != http.StatusCreated { t.Fatalf("second upload = %d %s", rec.Code, rec.Body.String()) } files, _ := out["data"].(map[string]any)["files"].([]any) if len(files) != 2 { t.Fatalf("files after two uploads = %v", out["data"]) } first := files[0].(map[string]any) if first["name"] != "wojna.pdf" || first["mime"] != "application/pdf" || !strings.HasSuffix(first["url"].(string), ".pdf") { t.Errorf("first file = %v", first) } if rec, _ := upload(base+"/files", map[string][]byte{"x.exe": []byte("MZ")}); rec.Code != http.StatusUnprocessableEntity { t.Errorf("disallowed type = %d %s", rec.Code, rec.Body.String()) } if rec, _ := upload(base+"/files", nil); rec.Code != http.StatusUnprocessableEntity { t.Errorf("no file = %d %s", rec.Code, rec.Body.String()) } if rec, _ := upload("/api/v1/bm/materials/999999/files", map[string][]byte{"a.pdf": pdf}); rec.Code != http.StatusNotFound { t.Errorf("unknown material = %d %s", rec.Code, rec.Body.String()) } if n := countFiles(); n != 2 { t.Fatalf("system_files rows = %d after refusals, want 2", n) } fileID := strconv.Itoa(int(first["id"].(float64))) if rec, _ := do(httptest.NewRequest(http.MethodDelete, "/api/v1/bm/materials/999999/files/"+fileID, nil)); rec.Code != http.StatusNotFound { t.Errorf("detach through another material = %d", rec.Code) } if rec, _ := do(httptest.NewRequest(http.MethodDelete, base+"/files/"+fileID, nil)); rec.Code != http.StatusNoContent { t.Fatalf("detach = %d %s", rec.Code, rec.Body.String()) } if n := countFiles(); n != 1 { t.Errorf("system_files rows = %d after detach, want 1", n) } if rec, _ := do(httptest.NewRequest(http.MethodDelete, base, nil)); rec.Code != http.StatusNoContent { t.Fatalf("delete material = %d %s", rec.Code, rec.Body.String()) } if n := countFiles(); n != 0 { t.Errorf("system_files rows = %d after deleting the material, want 0", n) } iter := bucket.List(nil) if obj, err := iter.Next(t.Context()); err != io.EOF { t.Errorf("a blob is left in the bucket: %v %v", obj, err) } }