package user import ( "bytes" "context" "encoding/json" "errors" "io" "mime/multipart" "net/http" "strconv" "strings" "testing" "time" "git.golem15.com/golem15/sm-user-plugin/classes" "git.golem15.com/golem15/sm-user-plugin/models" "git.golem15.com/golem15/summercms/modules/cabana" "git.golem15.com/golem15/summercms/modules/lagoon/attach" "gocloud.dev/blob" "gocloud.dev/blob/memblob" ) // uploadOrganisationFile posts an image to an organisation form's file // field under a form session key. func (e *adminEnv) uploadOrganisationFile(t *testing.T, token string, id uint, field, name, sessionKey string) { t.Helper() var buf bytes.Buffer mw := multipart.NewWriter(&buf) part, err := mw.CreateFormFile("file_data", name) if err != nil { t.Fatal(err) } if _, err := part.Write(jpegBytes(t)); err != nil { t.Fatal(err) } if err := mw.Close(); err != nil { t.Fatal(err) } if up := e.send(t, http.MethodPost, organisationPath(id, "/files/"+field), token, buf.Bytes(), mw.FormDataContentType(), map[string]string{cabana.SessionKeyHeader: sessionKey}); up.Code != http.StatusCreated && up.Code != http.StatusOK { t.Fatalf("%s upload = %d %s", field, up.Code, up.Body.String()) } } // blobKeysUnder lists the bucket's keys under prefix. func (e *adminEnv) blobKeysUnder(t *testing.T, prefix string) []string { t.Helper() var keys []string it := e.bucket.List(&blob.ListOptions{Prefix: prefix}) for { obj, err := it.Next(t.Context()) if errors.Is(err, io.EOF) { return keys } if err != nil { t.Fatal(err) } keys = append(keys, obj.Key) } } // organisationFiles counts an organisation's system_files rows of field. func (e *adminEnv) organisationFiles(t *testing.T, id uint, field string) int64 { t.Helper() var n int64 if err := e.gdb.Raw(`SELECT COUNT(*) FROM system_files WHERE attachment_type = ? AND attachment_id = ? AND field = ?`, (models.Organisation{}).MorphName(), strconv.FormatUint(uint64(id), 10), field).Scan(&n).Error; err != nil { t.Fatal(err) } return n } // TestOrganisationLogo pins E-1 (P-38): the Organisations form has a logo // fileupload right after the avatar; a logo uploaded through the admin form // API is stored as a system_files row with field logo; OrganisationLogoFields // answers has_logo, the disk name and a URL, or false and nils without a // logo, falling back to the original's public URL when the thumbnail fails; // the avatar keeps working beside it; and deleting the organisation // removes the logo attachment as it does the avatar, rows and blobs. // Postgres only. func TestOrganisationLogo(t *testing.T) { if testing.Short() { t.Skip("Postgres") } env := newAdminEnv(t) token := env.admin(t, permAccessUsers) ctx := context.Background() t.Run("the form has a logo fileupload next to the avatar", func(t *testing.T) { rec := env.expect(t, http.StatusOK, http.MethodGet, adminAPI(adminOrganisationsPath+"/schema/form"), token, nil) var schema struct { Data struct { Fields []struct { Name string `json:"name"` Type string `json:"type"` } `json:"fields"` } `json:"data"` } if err := json.Unmarshal(rec.Body.Bytes(), &schema); err != nil { t.Fatal(err) } at := -1 for i, f := range schema.Data.Fields { if f.Name == "avatar" { at = i } } if at < 0 || at+1 >= len(schema.Data.Fields) || schema.Data.Fields[at+1].Name != models.OrganisationLogoField || schema.Data.Fields[at+1].Type != "fileupload" { t.Fatalf("form fields = %+v, want logo:fileupload right after avatar", schema.Data.Fields) } }) t.Run("without a logo the fields answer false and nils", func(t *testing.T) { org := env.seedOrganisation(t, "No Logo "+adminStamp()) fields, err := classes.OrganisationLogoFields(ctx, env.app, org) if err != nil { t.Fatal(err) } if fields["has_logo"] != false || fields["logo"] != nil || fields["logo_url"] != nil { t.Fatalf("fields = %+v", fields) } if _, ok, err := classes.OrganisationLogoURL(ctx, env.app, org, 64); err != nil || ok { t.Fatalf("OrganisationLogoURL = %v, %v", ok, err) } }) t.Run("upload, payload fields, the avatar beside it and the delete", func(t *testing.T) { name := "Logo Org " + adminStamp() org := env.seedOrganisation(t, name) key := adminSessionKey(t) env.uploadOrganisationFile(t, token, org.ID, "avatar", "avatar.jpg", key) env.uploadOrganisationFile(t, token, org.ID, models.OrganisationLogoField, "logo.jpg", key) body, err := json.Marshal(map[string]any{"name": name, "slug": org.Slug}) if err != nil { t.Fatal(err) } if saved := env.send(t, http.MethodPut, organisationPath(org.ID, ""), token, body, "application/json", map[string]string{cabana.SessionKeyHeader: key}); saved.Code != http.StatusOK { t.Fatalf("save = %d %s", saved.Code, saved.Body.String()) } if logos, avatars := env.organisationFiles(t, org.ID, "logo"), env.organisationFiles(t, org.ID, "avatar"); logos != 1 || avatars != 1 { t.Fatalf("system_files rows: logo %d, avatar %d, want 1 and 1", logos, avatars) } fields, err := classes.OrganisationLogoFields(ctx, env.app, env.organisation(t, org.ID)) if err != nil { t.Fatal(err) } disk, _ := fields["logo"].(string) url, _ := fields["logo_url"].(string) if fields["has_logo"] != true || disk == "" || url == "" { t.Fatalf("fields = %+v", fields) } var logoDisk, avatarDisk string if err := env.gdb.Raw(`SELECT disk_name FROM system_files WHERE attachment_type = ? AND attachment_id = ? AND field = 'logo'`, (models.Organisation{}).MorphName(), strconv.FormatUint(uint64(org.ID), 10)).Scan(&logoDisk).Error; err != nil { t.Fatal(err) } if err := env.gdb.Raw(`SELECT disk_name FROM system_files WHERE attachment_type = ? AND attachment_id = ? AND field = 'avatar'`, (models.Organisation{}).MorphName(), strconv.FormatUint(uint64(org.ID), 10)).Scan(&avatarDisk).Error; err != nil { t.Fatal(err) } if disk != logoDisk || disk == avatarDisk { t.Fatalf("logo disk name %q, stored logo %q, avatar %q", disk, logoDisk, avatarDisk) } if strings.Contains(url, avatarDisk) { t.Fatalf("logo URL %q points at the avatar", url) } // A thumbnail that cannot be made (the storage fails) falls back to // the original's public URL, under the public path prefix // (RC-IN-03). closed := memblob.OpenBucket(nil) _ = closed.Close() failing := sessionUserApp(t, "SUMMER_GOLEM15__USER__JWT__SECRET="+sessionSecret) if err := failing.Publish(env.gdb); err != nil { t.Fatal(err) } if err := attach.Publish(failing, closed); err != nil { t.Fatal(err) } if got, ok, err := classes.OrganisationLogoURL(ctx, failing, env.organisation(t, org.ID), 64); err != nil || !ok || got != attach.PublicURL(attach.BlobKey(logoDisk)) { t.Fatalf("failing logo thumbnail = %q %v %v, want %s", got, ok, err, attach.PublicURL(attach.BlobKey(logoDisk))) } for _, disk := range []string{logoDisk, avatarDisk} { if keys := env.blobKeysUnder(t, attach.PartitionDirectory(disk)); len(keys) == 0 { t.Fatalf("no blob stored for %s before the delete", disk) } } env.expect(t, http.StatusOK, http.MethodDelete, organisationPath(org.ID, ""), token, nil) if logos, avatars := env.organisationFiles(t, org.ID, "logo"), env.organisationFiles(t, org.ID, "avatar"); logos != 0 || avatars != 0 { t.Fatalf("after the delete: logo rows %d, avatar rows %d, want none", logos, avatars) } // The blobs (originals and thumbnails) go once the delete commits // (RC-IN-05). deadline := time.Now().Add(5 * time.Second) for _, disk := range []string{logoDisk, avatarDisk} { for { keys := env.blobKeysUnder(t, attach.PartitionDirectory(disk)) if len(keys) == 0 { break } if time.Now().After(deadline) { t.Fatalf("blobs left after the delete: %v", keys) } time.Sleep(20 * time.Millisecond) } } }) }