diff --git a/README.md b/README.md index 9da0b9a..948576f 100644 --- a/README.md +++ b/README.md @@ -325,6 +325,6 @@ go test ./... # full run, starts Postgres with testcontainers (needs D `TestBackendLinkSmoke` runs the account link end to end: My account offers `link`, the action answers the site URL with a 64-hex code, the show route answers the administrator's login and expiry, a session older than the request gets `stale_session`, a fresh one links (the published resolver answers the administrator), the code is one-shot, a second frontend user gets `already_linked`, an expired code is 404, unlink and deleting the frontend user remove the link, and the subject events fire. `TestBackendLinkAuthTimeSmoke` checks the `auth_time` proof: a session signed in before the request and refreshed after it gets `stale_session`, a sign-in after the request links, `POST /backend-link/show` answers for the code in the body, and My account's URL carries the code in the fragment. `TestBackendLinkAdminSmoke` covers the Administrators screen actions: offered only as they apply and only with `golem15.users.manage_backend_links`, the case-insensitive email match, `linked_by`, `BackendLinkedEvent`, the 422 refusals and the unlink. `TestUserMCPGuardSmoke` covers the `user` guard provider (requirements, the scope and group-permission checks, the dispatch credential, personal token resolution), `GET /token/me` and the revoke, group and user subject events. `TestFrontendPermissionCatalogSmoke` boots a test plugin that declares frontend permission codes and checks both editors' options. `TestMemberTokenScopesSmoke` checks the member token scope catalog (duplicate, built-in and bad names fail) and, with Docker, minting, the scope check, the list, `GET /tokens/scopes` and a second application refusing the scope. The others need Docker. -`TestMemberTokenScopesBoot` (no Docker) checks that the scope Boot step fails for a name two plugins declare (naming both), for a redeclared `read`, `write` or `ai`, for a bad name, an empty label and a missing phrase key. With Docker: `TestMemberTokenScopes` walks the member token scope acceptance list item by item (mint and `middleware.Scope`, the 403 message, the 422 for an undeclared scope, the list, discovery in en and pl, the per-application catalog and the legacy fallback); `TestBackendLinkAuthTime` checks the link proof against login, request, refresh and re-login orders, a token without `auth_time` and another user's session token; `TestBackendLinkShowPost` checks that `POST /backend-link/show` answers like the GET route with `Cache-Control: no-store, private`, the fragment URL and `backend_links.page_path`; `TestAccessRevocation` checks `AccessRevokedEvent` from the ban action, the password reset, the password change and an administrator setting a password (ids, `Before`, reason, the transaction, the subject event after commit, the rollback on a failing listener) and that a banned user loses the backend link until unbanned; `TestUserMCPGuardClientIP` checks `last_used_ip` on the MCP personal-token path; `TestAPITokenRevokeEvent` checks the token-specific revoked event of `DELETE /tokens/{id}`; `TestOrganisationLogo` checks the logo field, the upload, `OrganisationLogoFields` with and without a logo, and that deleting an organisation removes its avatar and logo attachments. +`TestMemberTokenScopesBoot` (no Docker) checks that the scope Boot step fails for a name two plugins declare (naming both), for a redeclared `read`, `write` or `ai`, for a bad name, an empty label and a missing phrase key. With Docker: `TestMemberTokenScopes` walks the member token scope acceptance list item by item (mint and `middleware.Scope`, the 403 message, the 422 for an undeclared scope, the list, discovery in en and pl, the per-application catalog and the legacy fallback); `TestBackendLinkAuthTime` checks the link proof against login, request, refresh and re-login orders, a token without `auth_time` and another user's session token; `TestBackendLinkShowPost` checks that `POST /backend-link/show` answers like the GET route with `Cache-Control: no-store, private`, the fragment URL and `backend_links.page_path`; `TestAccessRevocation` checks `AccessRevokedEvent` from the ban action, the password reset, the password change and an administrator setting a password (ids, `Before`, reason, the transaction, the subject event after commit, the rollback on a failing listener) and that a banned user loses the backend link until unbanned; `TestUserMCPGuardClientIP` checks `last_used_ip` on the MCP personal-token path; `TestAPITokenRevokeEvent` checks the token-specific revoked event of `DELETE /tokens/{id}`; `TestOrganisationLogo` checks the logo field, the upload, `OrganisationLogoFields` with and without a logo, the URL fallback when the thumbnail fails, and that deleting an organisation removes its avatar and logo attachments, the rows and, once the delete commits, the blobs and thumbnails. The admin tests (`TestAdmin*`) boot the plugin with the admin API mounted on a database of their own and sign in backend administrators with a chosen permission set; the helpers are in `admin_harness_test.go`. diff --git a/access_revocation_test.go b/access_revocation_test.go index f6e3cd9..6b8d0b5 100644 --- a/access_revocation_test.go +++ b/access_revocation_test.go @@ -110,10 +110,10 @@ func wholeSecondsUntruncated(t *testing.T, env *adminEnv, id uint, before time.T // revoked subject event per user after the commit; the ban fires only for // newly banned users; a failing listener rolls a password reset back; and // a banned linked user loses the backend link until unbanned. Postgres -// only: under -short nothing runs. +// only: skipped under -short. func TestAccessRevocation(t *testing.T) { if testing.Short() { - return + t.Skip("Postgres") } env := newAdminEnv(t) spy := env.spySubjects() diff --git a/api_token_events_test.go b/api_token_events_test.go index 8440764..43d4388 100644 --- a/api_token_events_test.go +++ b/api_token_events_test.go @@ -12,10 +12,10 @@ import ( // TestAPITokenRevokeEvent pins plan 08's token-specific MCP revocation // (C-IN-02, user side): DELETE /tokens/{id} fires exactly one revoked // subject event for the user carrying that TokenID, so only the sessions of -// that token end. Postgres only: under -short nothing runs. +// that token end. Postgres only: skipped under -short. func TestAPITokenRevokeEvent(t *testing.T) { if testing.Short() { - return + t.Skip("Postgres") } env := newAdminEnv(t) spy := env.spySubjects() diff --git a/backend_link_auth_time_test.go b/backend_link_auth_time_test.go index ab337a0..0f0af4b 100644 --- a/backend_link_auth_time_test.go +++ b/backend_link_auth_time_test.go @@ -20,10 +20,10 @@ import ( // session signed in before the request stays stale through a refresh, a // sign-in after the request confirms (also after a refresh), a token // without auth_time is stale, and a session token of another user than the -// authenticated one is 401. Postgres only: under -short nothing runs. +// authenticated one is 401. Postgres only: skipped under -short. func TestBackendLinkAuthTime(t *testing.T) { if testing.Short() { - return + t.Skip("Postgres") } env := newAdminEnv(t) login := func(t *testing.T, user *models.User) string { diff --git a/backend_link_show_test.go b/backend_link_show_test.go index 71f4b74..723cf6b 100644 --- a/backend_link_show_test.go +++ b/backend_link_show_test.go @@ -20,11 +20,11 @@ import ( // show or confirm POST is ignored (RC-IN-01); My account answers the link as // /link-admin#code=<64 hex> with no query string; // golem15.user.backend_links.page_path changes the path, and a value -// without a leading slash gets one. Postgres only: under -short nothing -// runs. +// without a leading slash gets one. Postgres only: skipped under +// -short. func TestBackendLinkShowPost(t *testing.T) { if testing.Short() { - return + t.Skip("Postgres") } env := newAdminEnv(t) show := func(t *testing.T, code string) (get, post *struct { diff --git a/mcp_guard_client_ip_test.go b/mcp_guard_client_ip_test.go index 910df72..81c3d3c 100644 --- a/mcp_guard_client_ip_test.go +++ b/mcp_guard_client_ip_test.go @@ -13,10 +13,10 @@ import ( // personal-token path (C-IN-04): MCPResolvePersonalToken stamps // last_used_ip from towel.ClientIP, as the api_token guard does, and leaves // the column as it was when the context carries no address. Postgres only: -// under -short nothing runs. +// skipped under -short. func TestUserMCPGuardClientIP(t *testing.T) { if testing.Short() { - return + t.Skip("Postgres") } env := newAdminEnv(t) resolver, ok := env.app.Lookup[pact.MCPPersonalTokenResolver]() diff --git a/member_token_scopes_test.go b/member_token_scopes_test.go index d8944f5..dc18b1c 100644 --- a/member_token_scopes_test.go +++ b/member_token_scopes_test.go @@ -59,10 +59,10 @@ type scopeList struct { // TestMemberTokenScopes pins E-2's acceptance list item by item (each // subtest quotes it) with a plugin declaring servers:read and servers:write, // plus the per-application catalog and the legacy MintAPIToken fallback. -// Postgres only: under -short nothing runs. +// Postgres only: skipped under -short. func TestMemberTokenScopes(t *testing.T) { if testing.Short() { - return + t.Skip("Postgres") } t.Cleanup(classes.ResetDeclaredAPITokenScopes) env := newAdminEnvWith(t, []string{"acme.inventory"}) diff --git a/organisation_logo_test.go b/organisation_logo_test.go index a118d75..43c5a05 100644 --- a/organisation_logo_test.go +++ b/organisation_logo_test.go @@ -4,16 +4,20 @@ 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" ) @@ -38,6 +42,23 @@ func (e *adminEnv) uploadOrganisationFile(t *testing.T, token string, id uint, f } } +// 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() @@ -55,11 +76,11 @@ func (e *adminEnv) organisationFiles(t *testing.T, id uint, field string) int64 // 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. Postgres only: under -// -short nothing runs. +// removes the logo attachment as it does the avatar, rows and blobs. +// Postgres only. func TestOrganisationLogo(t *testing.T) { if testing.Short() { - return + t.Skip("Postgres") } env := newAdminEnv(t) token := env.admin(t, permAccessUsers) @@ -161,9 +182,30 @@ func TestOrganisationLogo(t *testing.T) { 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) + } + } }) }