ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m17s
ci / ts / ts (packages + web) (push) Successful in 3m13s
ci / api / api (push) Successful in 59s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m31s
ci / db / db (push) Successful in 2m50s
165 lines
6.9 KiB
Go
165 lines
6.9 KiB
Go
package auth_test
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"pipu/api/internal/api/gen"
|
|
"pipu/api/internal/auth"
|
|
)
|
|
|
|
func TestUserArchiveRoundTrip(t *testing.T) {
|
|
src := newEnv(t)
|
|
src.addUser("root", "root-original-password", auth.RoleAdmin)
|
|
src.addUser("listener", "listener-original-password", auth.RoleUser, func(u *auth.User) { u.ReviewsUploads = true })
|
|
src.addUser("staff", "staff-original-password", auth.RoleModerator, func(u *auth.User) { u.ManagesUpdates = true })
|
|
src.addUser("blocked", "blocked-original-password", auth.RoleUser, func(u *auth.User) {
|
|
u.IsBlocked = true
|
|
u.MustChangePassword = true
|
|
})
|
|
token := src.signIn("root", "root-original-password")
|
|
r := src.do(http.MethodGet, "/api/v1/admin/users/export", nil, bearer(token))
|
|
require.Equal(t, 200, r.Code, r.Body.String())
|
|
require.Equal(t, "no-store", r.Header().Get("Cache-Control"))
|
|
require.Contains(t, r.Header().Get("Content-Disposition"), "attachment")
|
|
var archive gen.UserArchive
|
|
require.NoError(t, json.Unmarshal(r.Body.Bytes(), &archive))
|
|
require.Len(t, archive.Users, 4)
|
|
for _, field := range []string{"ui_prefs", "refresh", "last_login_at", "recommendations", "history", "likes", "token_version"} {
|
|
require.NotContains(t, r.Body.String(), `"`+field+`"`)
|
|
}
|
|
for _, audit := range src.store.Audit {
|
|
require.NotContains(t, fmt.Sprint(audit.Payload), "$argon2id$")
|
|
}
|
|
|
|
dst := newEnv(t)
|
|
root := dst.addUser("root", "destination-root-password", auth.RoleAdmin)
|
|
destToken := dst.signIn("root", "destination-root-password")
|
|
result := dst.do(http.MethodPost, "/api/v1/admin/users/import", archive, bearer(destToken))
|
|
require.Equal(t, 200, result.Code, result.Body.String())
|
|
require.EqualValues(t, 3, result.body["created"])
|
|
require.Equal(t, []any{"root"}, result.body["skipped"])
|
|
for _, username := range []string{"listener", "staff", "blocked"} {
|
|
original, err := src.store.GetUserByUsername(context.Background(), username)
|
|
require.NoError(t, err)
|
|
imported, err := dst.store.GetUserByUsername(context.Background(), username)
|
|
require.NoError(t, err)
|
|
require.NotEqual(t, original.ID, imported.ID)
|
|
require.Equal(t, original.PasswordHash, imported.PasswordHash)
|
|
require.Equal(t, original.Role, imported.Role)
|
|
require.Equal(t, original.IsBlocked, imported.IsBlocked)
|
|
require.Equal(t, original.MustChangePassword, imported.MustChangePassword)
|
|
require.Equal(t, original.ReviewsUploads, imported.ReviewsUploads)
|
|
require.Equal(t, original.ManagesUpdates, imported.ManagesUpdates)
|
|
}
|
|
retained, err := dst.store.GetUserByUsername(context.Background(), "root")
|
|
require.NoError(t, err)
|
|
require.Equal(t, root.PasswordHash, retained.PasswordHash)
|
|
require.Len(t, dst.store.Tokens, 1, "sessions are not imported")
|
|
require.Empty(t, dst.store.RecResets)
|
|
dst.mustLogin("listener", "listener-original-password", "web")
|
|
dst.mustLogin("staff", "staff-original-password", "web")
|
|
result = dst.do(http.MethodPost, "/api/v1/admin/users/import", archive, bearer(destToken))
|
|
require.Equal(t, 200, result.Code)
|
|
require.EqualValues(t, 0, result.body["created"], "import is idempotent")
|
|
}
|
|
|
|
func TestUserArchiveAdminOnly(t *testing.T) {
|
|
for _, role := range []auth.Role{auth.RoleUser, auth.RoleModerator} {
|
|
t.Run(string(role), func(t *testing.T) {
|
|
e := newEnv(t)
|
|
e.addUser("actor", "actor-password-123", role)
|
|
token := e.signIn("actor", "actor-password-123")
|
|
for _, endpoint := range []struct {
|
|
method, path string
|
|
body any
|
|
}{
|
|
{http.MethodGet, "/api/v1/admin/users/export", nil},
|
|
{http.MethodPost, "/api/v1/admin/users/import", gen.UserArchive{Version: 1, Users: []gen.UserArchiveEntry{}}},
|
|
} {
|
|
r := e.do(endpoint.method, endpoint.path, endpoint.body, bearer(token))
|
|
require.Equal(t, 403, r.Code)
|
|
require.NotContains(t, r.Body.String(), "$argon2id$")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestUserArchiveValidationBeforeWrites(t *testing.T) {
|
|
e := newEnv(t)
|
|
root := e.addUser("root", "root-password-123", auth.RoleAdmin)
|
|
hash, err := e.hasher.Hash("valid-password-123")
|
|
require.NoError(t, err)
|
|
valid := auth.ArchivedUser{Username: "new-user", Role: auth.RoleUser, PasswordHash: hash}
|
|
for name, change := range map[string]func(*auth.ArchivedUser){
|
|
"hash": func(a *auth.ArchivedUser) { a.PasswordHash = "invalid" },
|
|
"memory": func(a *auth.ArchivedUser) { a.PasswordHash = strings.Replace(hash, "m=64,", "m=4294967295,", 1) },
|
|
"zero threads": func(a *auth.ArchivedUser) { a.PasswordHash = strings.Replace(hash, "p=1$", "p=0$", 1) },
|
|
"role": func(a *auth.ArchivedUser) { a.Role = "owner" },
|
|
"username": func(a *auth.ArchivedUser) { a.Username = "../invalid" },
|
|
"duplicate": func(a *auth.ArchivedUser) { a.Username = valid.Username },
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
bad := valid
|
|
bad.Username = "other-user"
|
|
change(&bad)
|
|
_, _, err := e.svc.ImportUsers(context.Background(), auth.Principal{UserID: root.ID, Role: root.Role}, 1, []auth.ArchivedUser{valid, bad})
|
|
require.Error(t, err)
|
|
require.Len(t, e.store.Users, 1, "no partial writes")
|
|
})
|
|
}
|
|
}
|
|
|
|
type failArchiveAudit struct{ auth.Store }
|
|
|
|
func (s failArchiveAudit) WithTx(ctx context.Context, fn func(auth.Store) error) error {
|
|
return s.Store.WithTx(ctx, func(tx auth.Store) error { return fn(failArchiveAudit{tx}) })
|
|
}
|
|
|
|
func (s failArchiveAudit) InsertAudit(context.Context, uuid.UUID, auth.AuditEntry) error {
|
|
return errors.New("audit unavailable")
|
|
}
|
|
|
|
func TestUserArchiveRollsBackOnStoreFailure(t *testing.T) {
|
|
e := newEnv(t)
|
|
root := e.addUser("root", "root-password-123", auth.RoleAdmin)
|
|
svc, err := auth.NewService(failArchiveAudit{e.store}, e.hasher, nil, e.limiter, e.clock, auth.UUIDv7{}, nil, auth.DefaultConfig())
|
|
require.NoError(t, err)
|
|
_, _, err = svc.ImportUsers(context.Background(), auth.Principal{UserID: root.ID, Role: root.Role}, 1,
|
|
[]auth.ArchivedUser{{Username: "new-user", Role: auth.RoleUser, PasswordHash: root.PasswordHash}})
|
|
require.ErrorContains(t, err, "audit unavailable")
|
|
require.Len(t, e.store.Users, 1)
|
|
}
|
|
|
|
func TestUserArchiveExportsAllPages(t *testing.T) {
|
|
e := newEnv(t)
|
|
root := e.addUser("root", "root-password-123", auth.RoleAdmin)
|
|
for i := range 205 {
|
|
u := root
|
|
u.ID, u.Username, u.Role = uuid.New(), fmt.Sprintf("listener-%03d", i), auth.RoleUser
|
|
require.NoError(t, e.store.CreateUser(context.Background(), u, nil))
|
|
}
|
|
users, err := e.svc.ExportUsers(context.Background(), auth.Principal{UserID: root.ID, Role: root.Role})
|
|
require.NoError(t, err)
|
|
require.Len(t, users, 206)
|
|
}
|
|
|
|
func TestUserArchiveRejectsOversizedRequest(t *testing.T) {
|
|
e := newEnv(t)
|
|
e.addUser("root", "root-password-123", auth.RoleAdmin)
|
|
token := e.signIn("root", "root-password-123")
|
|
r := e.do(http.MethodPost, "/api/v1/admin/users/import", map[string]any{
|
|
"version": 1, "users": []any{}, "padding": strings.Repeat("x", auth.MaxUserArchiveBytes),
|
|
}, bearer(token))
|
|
require.Equal(t, http.StatusBadRequest, r.Code)
|
|
require.Len(t, e.store.Users, 1)
|
|
}
|