Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c0c1281415 | ||
|
|
247277e139 | ||
|
|
f3b037c7f4 |
@@ -80,6 +80,16 @@ func run() error {
|
|||||||
log.Info("notes sealed", "count", sealed)
|
log.Info("notes sealed", "count", sealed)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Names created before the app capitalised them on the way in — "andreas"
|
||||||
|
// rather than "Andreas". Idempotent, so it is a cheap pass of names once it has
|
||||||
|
// caught up. A failure here does not stop the start: a lower-case first name is
|
||||||
|
// a blemish, not a broken instance.
|
||||||
|
if fixed, err := db.NormalizeUserNames(context.Background()); err != nil {
|
||||||
|
log.Error("normalize user names", "err", err)
|
||||||
|
} else if fixed > 0 {
|
||||||
|
log.Info("user names normalized", "count", fixed)
|
||||||
|
}
|
||||||
|
|
||||||
httpapi.Version = version
|
httpapi.Version = version
|
||||||
api := httpapi.New(cfg, db, log, webAssets(log))
|
api := httpapi.New(cfg, db, log, webAssets(log))
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNormalizeName(t *testing.T) {
|
||||||
|
cases := map[string]string{
|
||||||
|
"andreas": "Andreas",
|
||||||
|
"Andreas": "Andreas",
|
||||||
|
"anne mette": "Anne Mette",
|
||||||
|
" kristian ": "Kristian",
|
||||||
|
"anne mette": "Anne Mette",
|
||||||
|
"McDonald": "McDonald", // an existing inner capital is kept
|
||||||
|
"óscar": "Óscar", // Unicode initial
|
||||||
|
"æblegaard": "Æblegaard",
|
||||||
|
"": "",
|
||||||
|
" ": "",
|
||||||
|
"mette-marie sø": "Mette-marie Sø", // only word-initials are raised
|
||||||
|
"iPhone listen": "IPhone Listen", // deliberately: the first letter is raised
|
||||||
|
}
|
||||||
|
for in, want := range cases {
|
||||||
|
if got := NormalizeName(in); got != want {
|
||||||
|
t.Errorf("NormalizeName(%q) = %q, want %q", in, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateUserCapitalisesName(t *testing.T) {
|
||||||
|
db := openTest(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
u := &User{Email: "andreas@example.dk", Name: "andreas", PasswordHash: "x"}
|
||||||
|
if err := db.CreateUser(ctx, u, "Inbox"); err != nil {
|
||||||
|
t.Fatalf("create user: %v", err)
|
||||||
|
}
|
||||||
|
got, err := db.UserByEmail(ctx, "andreas@example.dk")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read back: %v", err)
|
||||||
|
}
|
||||||
|
if got.Name != "Andreas" {
|
||||||
|
t.Errorf("stored name = %q, want %q", got.Name, "Andreas")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeUserNamesBackfill(t *testing.T) {
|
||||||
|
db := openTest(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// An account as it looked before the app capitalised names: made properly,
|
||||||
|
// then set back to lower case behind the store's back to stand in for an old row.
|
||||||
|
u := &User{Email: "a@example.dk", Name: "Andreas", PasswordHash: "x"}
|
||||||
|
if err := db.CreateUser(ctx, u, "Inbox"); err != nil {
|
||||||
|
t.Fatalf("create user: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := db.ExecContext(ctx, `UPDATE users SET name = 'andreas' WHERE id = ?`, u.ID); err != nil {
|
||||||
|
t.Fatalf("downgrade name: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fixed, err := db.NormalizeUserNames(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("normalize: %v", err)
|
||||||
|
}
|
||||||
|
if fixed != 1 {
|
||||||
|
t.Fatalf("fixed = %d, want 1", fixed)
|
||||||
|
}
|
||||||
|
got, _ := db.UserByID(ctx, u.ID)
|
||||||
|
if got.Name != "Andreas" {
|
||||||
|
t.Errorf("name after backfill = %q, want %q", got.Name, "Andreas")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Idempotent: nothing left to do on a second pass.
|
||||||
|
again, err := db.NormalizeUserNames(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("normalize again: %v", err)
|
||||||
|
}
|
||||||
|
if again != 0 {
|
||||||
|
t.Errorf("second pass fixed = %d, want 0", again)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
"github.com/kristianwind/verdande/internal/auth"
|
"github.com/kristianwind/verdande/internal/auth"
|
||||||
)
|
)
|
||||||
@@ -62,6 +63,26 @@ func NormalizeEmail(email string) string {
|
|||||||
return strings.ToLower(strings.TrimSpace(email))
|
return strings.ToLower(strings.TrimSpace(email))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NormalizeName trims a display name and capitalises the first letter of every
|
||||||
|
// word, leaving the rest of each word exactly as typed. So "andreas" becomes
|
||||||
|
// "Andreas" and "anne mette" becomes "Anne Mette", while a name that already
|
||||||
|
// carries a capital in the middle — "McDonald", an "iPhone" — keeps it: only the
|
||||||
|
// first letter of a word is ever raised, never lowered.
|
||||||
|
//
|
||||||
|
// A name is the person's to spell; the one thing the instance insists on is that
|
||||||
|
// it does not start in lower case, because a column of lower-case first names
|
||||||
|
// reads as a bug the person cannot fix from their own keyboard. Unicode-aware, so
|
||||||
|
// "æblegaard" and "óscar" are raised the same as an ASCII initial.
|
||||||
|
func NormalizeName(name string) string {
|
||||||
|
fields := strings.Fields(name)
|
||||||
|
for i, f := range fields {
|
||||||
|
r := []rune(f)
|
||||||
|
r[0] = unicode.ToUpper(r[0])
|
||||||
|
fields[i] = string(r)
|
||||||
|
}
|
||||||
|
return strings.Join(fields, " ")
|
||||||
|
}
|
||||||
|
|
||||||
// CreateUser inserts a user and gives them an Inbox, in one transaction.
|
// CreateUser inserts a user and gives them an Inbox, in one transaction.
|
||||||
//
|
//
|
||||||
// The Inbox is created here rather than lazily on first use because every other
|
// The Inbox is created here rather than lazily on first use because every other
|
||||||
@@ -70,6 +91,7 @@ func NormalizeEmail(email string) string {
|
|||||||
// of them end up racing to create the second one.
|
// of them end up racing to create the second one.
|
||||||
func (db *DB) CreateUser(ctx context.Context, u *User, inboxName string) error {
|
func (db *DB) CreateUser(ctx context.Context, u *User, inboxName string) error {
|
||||||
u.Email = NormalizeEmail(u.Email)
|
u.Email = NormalizeEmail(u.Email)
|
||||||
|
u.Name = NormalizeName(u.Name)
|
||||||
if u.ID == "" {
|
if u.ID == "" {
|
||||||
u.ID = NewID()
|
u.ID = NewID()
|
||||||
}
|
}
|
||||||
@@ -473,6 +495,7 @@ func (db *DB) UpdatePasswordHash(ctx context.Context, userID, hash, keepSessionI
|
|||||||
// invites were sent to, and changing it is a re-verification flow rather than a
|
// invites were sent to, and changing it is a re-verification flow rather than a
|
||||||
// field. Neither is is_admin — an account cannot promote itself.
|
// field. Neither is is_admin — an account cannot promote itself.
|
||||||
func (db *DB) UpdateProfile(ctx context.Context, userID, name, timezone, locale string) error {
|
func (db *DB) UpdateProfile(ctx context.Context, userID, name, timezone, locale string) error {
|
||||||
|
name = NormalizeName(name)
|
||||||
res, err := db.ExecContext(ctx,
|
res, err := db.ExecContext(ctx,
|
||||||
`UPDATE users SET name = ?, timezone = ?, locale = ?, updated_at = ? WHERE id = ?`,
|
`UPDATE users SET name = ?, timezone = ?, locale = ?, updated_at = ? WHERE id = ?`,
|
||||||
name, timezone, locale, time.Now().Unix(), userID)
|
name, timezone, locale, time.Now().Unix(), userID)
|
||||||
@@ -485,6 +508,56 @@ func (db *DB) UpdateProfile(ctx context.Context, userID, name, timezone, locale
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NormalizeUserNames capitalises the first letter of every existing user's name,
|
||||||
|
// for the accounts created before the app started doing it on the way in.
|
||||||
|
//
|
||||||
|
// Idempotent: a name already normalised is left untouched, so it costs one pass
|
||||||
|
// of names at each start and writes nothing once it has caught up. Like SealNotes
|
||||||
|
// it runs at boot rather than as a migration, because the rule lives in Go —
|
||||||
|
// Unicode-aware and per word — and SQLite's own UPPER cannot spell it.
|
||||||
|
//
|
||||||
|
// `updated_at` is not touched: fixing the spelling of a name the person chose is
|
||||||
|
// the instance tidying up after itself, not the person editing their profile.
|
||||||
|
func (db *DB) NormalizeUserNames(ctx context.Context) (int, error) {
|
||||||
|
rows, err := db.QueryContext(ctx, `SELECT id, name FROM users`)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
type pending struct{ id, name string }
|
||||||
|
var todo []pending
|
||||||
|
for rows.Next() {
|
||||||
|
var id, name string
|
||||||
|
if err := rows.Scan(&id, &name); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if n := NormalizeName(name); n != name {
|
||||||
|
todo = append(todo, pending{id, n})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if len(todo) == 0 {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
err = db.Tx(ctx, func(tx *sql.Tx) error {
|
||||||
|
for _, p := range todo {
|
||||||
|
if _, err := tx.ExecContext(ctx,
|
||||||
|
`UPDATE users SET name = ? WHERE id = ?`, p.name, p.id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return len(todo), nil
|
||||||
|
}
|
||||||
|
|
||||||
func (db *DB) SetTOTPSecret(ctx context.Context, userID, secret string, enabled bool) error {
|
func (db *DB) SetTOTPSecret(ctx context.Context, userID, secret string, enabled bool) error {
|
||||||
// Sealed, like a mailbox password and for the same reason: the copies, not the
|
// Sealed, like a mailbox password and for the same reason: the copies, not the
|
||||||
// server. A TOTP secret in a downloadable backup lets whoever holds the copy
|
// server. A TOTP secret in a downloadable backup lets whoever holds the copy
|
||||||
|
|||||||
@@ -925,6 +925,8 @@ test('en rolle kan rettes uden at fjerne personen', async ({ browser, page }) =>
|
|||||||
const context = await browser.newContext({ storageState: { cookies: [], origins: [] } });
|
const context = await browser.newContext({ storageState: { cookies: [], origins: [] } });
|
||||||
const andreas = await context.newPage();
|
const andreas = await context.newPage();
|
||||||
await andreas.goto(link);
|
await andreas.goto(link);
|
||||||
|
// Typed in lower case on purpose: the instance capitalises a name on the way
|
||||||
|
// in, so from here on this person is "Andreas" everywhere they are shown.
|
||||||
await andreas.getByLabel('Navn', { exact: true }).fill('andreas');
|
await andreas.getByLabel('Navn', { exact: true }).fill('andreas');
|
||||||
await andreas.getByLabel(/Adgangskode/).fill('et langt kodeord til test');
|
await andreas.getByLabel(/Adgangskode/).fill('et langt kodeord til test');
|
||||||
await andreas.getByRole('button', { name: 'Opret konto' }).click();
|
await andreas.getByRole('button', { name: 'Opret konto' }).click();
|
||||||
@@ -943,7 +945,7 @@ test('en rolle kan rettes uden at fjerne personen', async ({ browser, page }) =>
|
|||||||
await page.reload();
|
await page.reload();
|
||||||
await projectAction(page, /Delt/);
|
await projectAction(page, /Delt/);
|
||||||
await expect(page.getByText('Ejer')).toBeVisible();
|
await expect(page.getByText('Ejer')).toBeVisible();
|
||||||
await page.getByLabel('Rolle for andreas').selectOption('editor');
|
await page.getByLabel('Rolle for Andreas').selectOption('editor');
|
||||||
|
|
||||||
await andreas.reload();
|
await andreas.reload();
|
||||||
await expect(andreas.getByLabel('Ny opgave')).toBeVisible();
|
await expect(andreas.getByLabel('Ny opgave')).toBeVisible();
|
||||||
@@ -956,11 +958,11 @@ test('en rolle kan rettes uden at fjerne personen', async ({ browser, page }) =>
|
|||||||
await page.getByLabel('Ny opgave').press('Enter');
|
await page.getByLabel('Ny opgave').press('Enter');
|
||||||
await page.getByText('brænde kaffe', { exact: true }).click();
|
await page.getByText('brænde kaffe', { exact: true }).click();
|
||||||
const drawer = page.getByRole('complementary', { name: 'Opgave' });
|
const drawer = page.getByRole('complementary', { name: 'Opgave' });
|
||||||
await drawer.getByLabel('Ansvarlig').selectOption({ label: 'andreas' });
|
await drawer.getByLabel('Ansvarlig').selectOption({ label: 'Andreas' });
|
||||||
await page.keyboard.press('Escape');
|
await page.keyboard.press('Escape');
|
||||||
|
|
||||||
const row = page.locator('.row').filter({ hasText: 'brænde kaffe' });
|
const row = page.locator('.row').filter({ hasText: 'brænde kaffe' });
|
||||||
await expect(row.getByTitle('Uddelegeret til andreas')).toBeVisible();
|
await expect(row.getByTitle('Uddelegeret til Andreas')).toBeVisible();
|
||||||
// And not on a task that is nobody's.
|
// And not on a task that is nobody's.
|
||||||
await page.getByLabel('Ny opgave').fill('min egen');
|
await page.getByLabel('Ny opgave').fill('min egen');
|
||||||
await page.getByLabel('Ny opgave').press('Enter');
|
await page.getByLabel('Ny opgave').press('Enter');
|
||||||
|
|||||||
Reference in New Issue
Block a user