feat: admin endpoints to reset username sync flags

Add POST /v1/admin/users/{pubkey}/reset-username and
POST /v1/admin/users/reset-usernames to clear manual_username
and last_synced_at so nostr profile sync re-evaluates users.
Includes OpenAPI docs, audit actions, and tests.
This commit is contained in:
2026-05-06 19:31:13 +00:00
parent c6bdb7f825
commit fe2b95258d
7 changed files with 290 additions and 0 deletions

View File

@@ -3,6 +3,7 @@ package http_test
import (
"bytes"
"context"
"database/sql"
"encoding/json"
"net/http"
"net/http/httptest"
@@ -353,6 +354,179 @@ func TestAdminAdd_BadInputs(t *testing.T) {
}
}
func TestAdminResetUsername(t *testing.T) {
f := newFixture(t)
resp, body := f.admin(t, "POST", "/v1/admin/users", map[string]any{
"pubkey": testHex, "username": "alice",
"subscription_type": "yearly", "years": 1,
})
if resp.StatusCode != 201 {
t.Fatalf("add status %d: %s", resp.StatusCode, body)
}
resp, body = f.admin(t, "PUT", "/v1/admin/users/"+testHex,
map[string]any{"username": "alice2"})
if resp.StatusCode != 200 {
t.Fatalf("update status %d: %s", resp.StatusCode, body)
}
var updated map[string]any
_ = json.Unmarshal(body, &updated)
if updated["manual_username"] != true {
t.Fatalf("expected manual_username=true after update, got %v", updated["manual_username"])
}
if _, err := f.db.ExecContext(context.Background(),
`UPDATE users SET last_synced_at = ? WHERE pubkey = ?`,
"2099-01-01T00:00:00Z", testHex); err != nil {
t.Fatal(err)
}
resp, body = f.admin(t, "POST", "/v1/admin/users/"+testHex+"/reset-username", nil)
if resp.StatusCode != 200 {
t.Fatalf("reset status %d: %s", resp.StatusCode, body)
}
var got map[string]any
if err := json.Unmarshal(body, &got); err != nil {
t.Fatal(err)
}
if got["manual_username"] != false {
t.Errorf("expected manual_username=false after reset, got %v (body %s)", got["manual_username"], body)
}
if got["username"] != "alice2" {
t.Errorf("expected username unchanged after reset, got %v", got["username"])
}
var lastSynced sql.NullString
if err := f.db.QueryRow(`SELECT last_synced_at FROM users WHERE pubkey = ?`, testHex).
Scan(&lastSynced); err != nil {
t.Fatal(err)
}
if lastSynced.Valid && lastSynced.String != "" {
t.Errorf("expected last_synced_at to be NULL after reset, got %q", lastSynced.String)
}
resp, body = f.admin(t, "POST", "/v1/admin/users/"+testHex+"/reset-username", nil)
if resp.StatusCode != 200 {
t.Fatalf("idempotent reset status %d: %s", resp.StatusCode, body)
}
if resp, _ := f.admin(t, "DELETE", "/v1/admin/users/"+testHex, nil); resp.StatusCode != 200 {
t.Fatalf("delete status %d", resp.StatusCode)
}
resp, _ = f.admin(t, "POST", "/v1/admin/users/"+testHex+"/reset-username", nil)
if resp.StatusCode != 404 {
t.Errorf("expected 404 for unknown pubkey, got %d", resp.StatusCode)
}
resp, _ = f.admin(t, "POST", "/v1/admin/users/notapubkey/reset-username", nil)
if resp.StatusCode != 400 {
t.Errorf("expected 400 for invalid pubkey, got %d", resp.StatusCode)
}
row := f.db.QueryRow(`SELECT COUNT(*) FROM audit_log WHERE action = ?`,
"user.username_reset")
var n int
if err := row.Scan(&n); err != nil {
t.Fatal(err)
}
if n < 1 {
t.Errorf("expected user.username_reset audit entry, got %d", n)
}
}
func TestAdminResetAllUsernames(t *testing.T) {
f := newFixture(t)
// Two more 64-char hex strings — not necessarily valid Schnorr keys, so we
// insert them via raw SQL to bypass the admin endpoint validation.
hexB := "1f8c41ebcd55a8d8db2e0a8c3a4b9c5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1d"
hexInactive := "2a8c41ebcd55a8d8db2e0a8c3a4b9c5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1e"
if resp, body := f.admin(t, "POST", "/v1/admin/users", map[string]any{
"pubkey": testHex, "username": "alice",
"subscription_type": "yearly", "years": 1,
}); resp.StatusCode != 201 {
t.Fatalf("add A status %d: %s", resp.StatusCode, body)
}
if resp, body := f.admin(t, "PUT", "/v1/admin/users/"+testHex,
map[string]any{"username": "alice2"}); resp.StatusCode != 200 {
t.Fatalf("pin A status %d: %s", resp.StatusCode, body)
}
if _, err := f.db.ExecContext(context.Background(),
`INSERT INTO users (pubkey, username, subscription_type, expires_at, is_active, manual_username, last_synced_at, created_at)
VALUES (?, 'bob', 'yearly', NULL, 1, 1, ?, datetime('now'))`,
hexB, "2099-01-01T00:00:00Z"); err != nil {
t.Fatal(err)
}
if _, err := f.db.ExecContext(context.Background(),
`INSERT INTO users (pubkey, username, subscription_type, expires_at, is_active, manual_username, last_synced_at, created_at)
VALUES (?, 'carol', 'yearly', NULL, 0, 1, ?, datetime('now'))`,
hexInactive, "2099-01-01T00:00:00Z"); err != nil {
t.Fatal(err)
}
if _, err := f.db.ExecContext(context.Background(),
`UPDATE users SET last_synced_at = ? WHERE pubkey = ?`,
"2099-01-01T00:00:00Z", testHex); err != nil {
t.Fatal(err)
}
resp, body := f.admin(t, "POST", "/v1/admin/users/reset-usernames", nil)
if resp.StatusCode != 200 {
t.Fatalf("bulk reset status %d: %s", resp.StatusCode, body)
}
var got map[string]any
if err := json.Unmarshal(body, &got); err != nil {
t.Fatal(err)
}
if cnt, _ := got["reset"].(float64); cnt != 2 {
t.Errorf("expected reset=2 (active users only), got %v (body %s)", got["reset"], body)
}
rows, err := f.db.Query(`SELECT pubkey, manual_username, last_synced_at FROM users`)
if err != nil {
t.Fatal(err)
}
defer rows.Close()
for rows.Next() {
var pk string
var manual int
var lastSynced sql.NullString
if err := rows.Scan(&pk, &manual, &lastSynced); err != nil {
t.Fatal(err)
}
switch pk {
case testHex, hexB:
if manual != 0 {
t.Errorf("%s: expected manual_username=0 after reset, got %d", pk, manual)
}
if lastSynced.Valid && lastSynced.String != "" {
t.Errorf("%s: expected last_synced_at NULL after reset, got %q", pk, lastSynced.String)
}
case hexInactive:
if manual != 1 {
t.Errorf("inactive user manual_username should be untouched, got %d", manual)
}
if !lastSynced.Valid || lastSynced.String == "" {
t.Errorf("inactive user last_synced_at should be untouched, got %v", lastSynced)
}
}
}
row := f.db.QueryRow(`SELECT COUNT(*) FROM audit_log WHERE action = ?`,
"users.usernames_reset")
var n int
if err := row.Scan(&n); err != nil {
t.Fatal(err)
}
if n != 1 {
t.Errorf("expected 1 users.usernames_reset audit entry, got %d", n)
}
}
func TestOpenAPI(t *testing.T) {
f := newFixture(t)
resp, body := f.get(t, "/openapi.json")