Phase 1: accounts and sessions
CI / Test (ubuntu-latest) (push) Canceled after 0s
CI / Test (windows-latest) (push) Canceled after 0s
CI / Storage tests on PostgreSQL (push) Canceled after 0s
CI / Complement (federation off) (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
CI / Smoke test the binary (push) Canceled after 0s
CI / Test (ubuntu-latest) (push) Canceled after 0s
CI / Test (windows-latest) (push) Canceled after 0s
CI / Storage tests on PostgreSQL (push) Canceled after 0s
CI / Complement (federation off) (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
CI / Smoke test the binary (push) Canceled after 0s
Implements PLAN.md Phase 1. A client can now register, log in, manage its devices and set its profile; an operator can create accounts and flip the runtime switches. There are still no rooms and no real /sync. userapi: accounts, argon2id passwords in PHC format, devices, access and refresh tokens stored as SHA-256 hashes, account data and the admin flag. Its storage interface is storage.UserStore, implemented once in sqlstore with `?` placeholders; the postgres driver supplies a rebind function and unique-violation recognition rather than keeping a second copy of thirty statements. Timestamps in these tables are int64 milliseconds, the unit the Client-Server API uses on the wire. Client-Server API: register (user-interactive auth with m.login.dummy and optional registration tokens), register/available, token validity, login with m.login.password, logout, logout/all, refresh, account/whoami, capabilities, devices/* with password-gated deletion, profile/*, global and per-room account data. GET /sync is a deliberate placeholder: it answers a valid token with an empty but well-formed response and refuses a revoked one. Phase 1 has no rooms, so that is the whole truth, and it is what makes session revocation observable. Phase 4 replaces it with the syncapi. Auth middleware resolves a bearer token (header or the legacy query parameter) to a device and user, answering an expired token with the soft logout the spec asks for. Rate limiting is a token bucket per endpoint class keyed on the client address, returning M_LIMIT_EXCEEDED with retry_after_ms. adminapi v1 under /_warp/admin/v1: admin-only auth via a normal Matrix token, GET/PUT /federation and /registration driving internal/settings and reporting a config-pinned switch as a 409, GET/POST /users, GET /stats. warp create-account [--admin] creates the first account straight in the database, so a new install with registration off has a way in. Complement is wired up for the federation=off profile: an image meeting its contract, an entrypoint that configures Warp entirely from WARP_* variables, and a runner that builds, clones and executes the target set (TestRegistration, TestLogin, TestDeviceManagement). It has not been run yet -- there is no container runtime on the development machine -- so the three tests' assertions were read from the Complement sources and pinned as httptest tests instead, and CI gained a job that runs the real thing. Also fixes a .gitignore bug from Phase 0: the unanchored `warp` pattern, meant for the built binary, also matched the directory server/cmd/warp and silently excluded the entire command package from the repository. The binary patterns are now anchored to the repository root, and cmd/warp is committed for the first time. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b4fb60a8ab
commit
6004bb01de
68 files changed
+9729
-66
No files matched your search
@@ -0,0 +1,494 @@
|
||||
package storage_test
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/dariuszjaniak/warp/server/internal/storage"
|
||||
)
|
||||
|
||||
// migratedTestDB opens a driver, brings its schema up to date and empties
|
||||
// the userapi tables, which is what every user-store test needs before it
|
||||
// can touch one.
|
||||
func migratedTestDB(t *testing.T, driver string) storage.Database {
|
||||
t.Helper()
|
||||
db := openTestDB(t, driver)
|
||||
if err := db.Migrate(t.Context()); err != nil {
|
||||
t.Fatalf("Migrate: %v", err)
|
||||
}
|
||||
resetUserTables(t, driver)
|
||||
return db
|
||||
}
|
||||
|
||||
// resetUserTables empties the userapi tables so that each test starts from a
|
||||
// known state.
|
||||
//
|
||||
// SQLite gets a fresh file per test and needs nothing. The PostgreSQL tests
|
||||
// all share the one database the environment points at, so without this the
|
||||
// second test to create "alice" would collide with the first — and would do
|
||||
// so only in CI, where PostgreSQL is available.
|
||||
func resetUserTables(t *testing.T, driver string) {
|
||||
t.Helper()
|
||||
if driver != storage.DriverPostgres {
|
||||
return
|
||||
}
|
||||
|
||||
// The storage interface has no "delete an account", on purpose:
|
||||
// deactivation is Phase 9 and deletion may never exist. Clearing the
|
||||
// tables is a test concern, so the test does it directly.
|
||||
db, err := sql.Open("pgx", os.Getenv("WARP_TEST_POSTGRES_DSN"))
|
||||
if err != nil {
|
||||
t.Fatalf("open postgres to reset the user tables: %v", err)
|
||||
}
|
||||
defer func() { _ = db.Close() }()
|
||||
|
||||
if _, err := db.ExecContext(t.Context(), `TRUNCATE
|
||||
user_account_data, user_refresh_tokens, user_access_tokens,
|
||||
user_devices, user_accounts`); err != nil {
|
||||
t.Fatalf("reset the user tables: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// newAccount is the minimum CreateAccount needs.
|
||||
func newAccount(localpart string) storage.NewAccount {
|
||||
return storage.NewAccount{
|
||||
Localpart: localpart,
|
||||
PasswordHash: "$argon2id$v=19$m=19456,t=2,p=1$c2FsdHNhbHQ$aGFzaA",
|
||||
CreatedAtMS: 1_700_000_000_000,
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserAccounts(t *testing.T) {
|
||||
for _, driver := range testDrivers {
|
||||
t.Run(driver, func(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
users := migratedTestDB(t, driver).Users()
|
||||
|
||||
if _, err := users.Account(ctx, "nobody"); !errors.Is(err, storage.ErrNoSuchUser) {
|
||||
t.Errorf("Account of an unknown user = %v, want ErrNoSuchUser", err)
|
||||
}
|
||||
|
||||
acc, err := users.CreateAccount(ctx, newAccount("alice"))
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAccount: %v", err)
|
||||
}
|
||||
if acc.Localpart != "alice" || acc.Admin || acc.Deactivated {
|
||||
t.Errorf("CreateAccount returned %+v", acc)
|
||||
}
|
||||
|
||||
// A taken localpart must be reported as such, not as a generic
|
||||
// database failure: registration turns it into M_USER_IN_USE.
|
||||
if _, err := users.CreateAccount(ctx, newAccount("alice")); !errors.Is(err, storage.ErrUserExists) {
|
||||
t.Errorf("duplicate CreateAccount = %v, want ErrUserExists", err)
|
||||
}
|
||||
|
||||
stored, err := users.Account(ctx, "alice")
|
||||
if err != nil {
|
||||
t.Fatalf("Account: %v", err)
|
||||
}
|
||||
if stored.CreatedAtMS != 1_700_000_000_000 {
|
||||
t.Errorf("created_at_ms = %d, want 1700000000000", stored.CreatedAtMS)
|
||||
}
|
||||
|
||||
hash, err := users.PasswordHash(ctx, "alice")
|
||||
if err != nil || hash == "" {
|
||||
t.Fatalf("PasswordHash = %q, %v", hash, err)
|
||||
}
|
||||
if err := users.SetPasswordHash(ctx, "alice", "newhash"); err != nil {
|
||||
t.Fatalf("SetPasswordHash: %v", err)
|
||||
}
|
||||
if hash, _ = users.PasswordHash(ctx, "alice"); hash != "newhash" {
|
||||
t.Errorf("PasswordHash after update = %q", hash)
|
||||
}
|
||||
|
||||
if err := users.SetAdmin(ctx, "alice", true); err != nil {
|
||||
t.Fatalf("SetAdmin: %v", err)
|
||||
}
|
||||
if err := users.SetDisplayName(ctx, "alice", "Alice"); err != nil {
|
||||
t.Fatalf("SetDisplayName: %v", err)
|
||||
}
|
||||
if err := users.SetAvatarURL(ctx, "alice", "mxc://warp.example/abc"); err != nil {
|
||||
t.Fatalf("SetAvatarURL: %v", err)
|
||||
}
|
||||
stored, err = users.Account(ctx, "alice")
|
||||
if err != nil {
|
||||
t.Fatalf("Account: %v", err)
|
||||
}
|
||||
if !stored.Admin {
|
||||
t.Error("the admin flag did not survive the round trip")
|
||||
}
|
||||
if stored.DisplayName != "Alice" || stored.AvatarURL != "mxc://warp.example/abc" {
|
||||
t.Errorf("profile = %+v", stored)
|
||||
}
|
||||
|
||||
// Updating an absent account is ErrNoSuchUser, so that callers
|
||||
// do not have to look it up first.
|
||||
if err := users.SetDisplayName(ctx, "nobody", "x"); !errors.Is(err, storage.ErrNoSuchUser) {
|
||||
t.Errorf("SetDisplayName on an unknown user = %v, want ErrNoSuchUser", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserAccountListingAndStats(t *testing.T) {
|
||||
for _, driver := range testDrivers {
|
||||
t.Run(driver, func(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
users := migratedTestDB(t, driver).Users()
|
||||
|
||||
for _, localpart := range []string{"amy", "bob", "bobby", "zoe_x"} {
|
||||
if _, err := users.CreateAccount(ctx, newAccount(localpart)); err != nil {
|
||||
t.Fatalf("CreateAccount(%s): %v", localpart, err)
|
||||
}
|
||||
}
|
||||
if err := users.SetAdmin(ctx, "amy", true); err != nil {
|
||||
t.Fatalf("SetAdmin: %v", err)
|
||||
}
|
||||
if err := users.SetDisplayName(ctx, "zoe_x", "Zoe"); err != nil {
|
||||
t.Fatalf("SetDisplayName: %v", err)
|
||||
}
|
||||
|
||||
all, total, err := users.ListAccounts(ctx, storage.AccountFilter{})
|
||||
if err != nil {
|
||||
t.Fatalf("ListAccounts: %v", err)
|
||||
}
|
||||
if total != 4 || len(all) != 4 {
|
||||
t.Fatalf("ListAccounts returned %d of %d, want 4 of 4", len(all), total)
|
||||
}
|
||||
if all[0].Localpart != "amy" || all[3].Localpart != "zoe_x" {
|
||||
t.Errorf("accounts are not ordered by localpart: %v", all)
|
||||
}
|
||||
|
||||
page, total, err := users.ListAccounts(ctx, storage.AccountFilter{Limit: 2, Offset: 1})
|
||||
if err != nil {
|
||||
t.Fatalf("ListAccounts paged: %v", err)
|
||||
}
|
||||
if total != 4 || len(page) != 2 || page[0].Localpart != "bob" {
|
||||
t.Errorf("paged listing = %v (total %d)", page, total)
|
||||
}
|
||||
|
||||
found, total, err := users.ListAccounts(ctx, storage.AccountFilter{Search: "BOB"})
|
||||
if err != nil {
|
||||
t.Fatalf("ListAccounts search: %v", err)
|
||||
}
|
||||
if total != 2 || len(found) != 2 {
|
||||
t.Errorf("case-insensitive search for bob found %d, want 2", total)
|
||||
}
|
||||
|
||||
// A display name is searchable too.
|
||||
if _, total, err = users.ListAccounts(ctx, storage.AccountFilter{Search: "zoe"}); err != nil {
|
||||
t.Fatalf("ListAccounts: %v", err)
|
||||
} else if total != 1 {
|
||||
t.Errorf("searching display names found %d, want 1", total)
|
||||
}
|
||||
|
||||
// "_" is a LIKE wildcard; escaping it is what keeps this from
|
||||
// matching every three-letter localpart.
|
||||
if _, total, err = users.ListAccounts(ctx, storage.AccountFilter{Search: "e_x"}); err != nil {
|
||||
t.Fatalf("ListAccounts: %v", err)
|
||||
} else if total != 1 {
|
||||
t.Errorf("searching for a literal underscore found %d, want 1", total)
|
||||
}
|
||||
|
||||
stats, err := users.Stats(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("Stats: %v", err)
|
||||
}
|
||||
if stats.Users != 4 || stats.Admins != 1 || stats.Deactivated != 0 || stats.Devices != 0 {
|
||||
t.Errorf("Stats = %+v", stats)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserSessions(t *testing.T) {
|
||||
for _, driver := range testDrivers {
|
||||
t.Run(driver, func(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
users := migratedTestDB(t, driver).Users()
|
||||
if _, err := users.CreateAccount(ctx, newAccount("alice")); err != nil {
|
||||
t.Fatalf("CreateAccount: %v", err)
|
||||
}
|
||||
|
||||
session := storage.NewSession{
|
||||
Localpart: "alice",
|
||||
DeviceID: "DEV1",
|
||||
DisplayName: "first login",
|
||||
CreatedAtMS: 1000,
|
||||
AccessTokenHash: "hash-a",
|
||||
LastSeenIP: "10.0.0.1",
|
||||
UserAgent: "warp-test",
|
||||
}
|
||||
device, err := users.CreateSession(ctx, session)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSession: %v", err)
|
||||
}
|
||||
if device.ID != "DEV1" || device.DisplayName != "first login" {
|
||||
t.Errorf("CreateSession returned %+v", device)
|
||||
}
|
||||
|
||||
// A session for an unknown account must fail loudly rather than
|
||||
// leave a device with no owner.
|
||||
orphan := session
|
||||
orphan.Localpart, orphan.AccessTokenHash = "nobody", "hash-orphan"
|
||||
if _, err := users.CreateSession(ctx, orphan); !errors.Is(err, storage.ErrNoSuchUser) {
|
||||
t.Errorf("CreateSession for an unknown user = %v, want ErrNoSuchUser", err)
|
||||
}
|
||||
|
||||
info, err := users.TokenInfo(ctx, "hash-a")
|
||||
if err != nil {
|
||||
t.Fatalf("TokenInfo: %v", err)
|
||||
}
|
||||
if info.Localpart != "alice" || info.DeviceID != "DEV1" || info.ExpiresAtMS != 0 {
|
||||
t.Errorf("TokenInfo = %+v", info)
|
||||
}
|
||||
if _, err := users.TokenInfo(ctx, "nope"); !errors.Is(err, storage.ErrNoSuchToken) {
|
||||
t.Errorf("TokenInfo of an unknown token = %v, want ErrNoSuchToken", err)
|
||||
}
|
||||
|
||||
// Logging in again on the same device keeps the device and its
|
||||
// display name, and revokes the token it had.
|
||||
again := session
|
||||
again.AccessTokenHash = "hash-b"
|
||||
again.DisplayName = "ignored on an existing device"
|
||||
again.CreatedAtMS = 2000
|
||||
device, err = users.CreateSession(ctx, again)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSession (same device): %v", err)
|
||||
}
|
||||
if device.DisplayName != "first login" {
|
||||
t.Errorf("display name = %q; initial_device_display_name applies to new devices only", device.DisplayName)
|
||||
}
|
||||
if device.CreatedAtMS != 1000 {
|
||||
t.Errorf("created_at_ms = %d; re-login must not reset it", device.CreatedAtMS)
|
||||
}
|
||||
if _, err := users.TokenInfo(ctx, "hash-a"); !errors.Is(err, storage.ErrNoSuchToken) {
|
||||
t.Error("the previous access token of a re-used device was not revoked")
|
||||
}
|
||||
if _, err := users.TokenInfo(ctx, "hash-b"); err != nil {
|
||||
t.Errorf("the new access token does not work: %v", err)
|
||||
}
|
||||
|
||||
stored, err := users.Device(ctx, "alice", "DEV1")
|
||||
if err != nil {
|
||||
t.Fatalf("Device: %v", err)
|
||||
}
|
||||
if stored.LastSeenMS != 2000 || stored.LastSeenIP != "10.0.0.1" {
|
||||
t.Errorf("device = %+v", stored)
|
||||
}
|
||||
if _, err := users.Device(ctx, "alice", "GONE"); !errors.Is(err, storage.ErrNoSuchDevice) {
|
||||
t.Errorf("Device of an unknown device = %v, want ErrNoSuchDevice", err)
|
||||
}
|
||||
|
||||
if err := users.TouchDevice(ctx, "alice", "DEV1", "10.0.0.2", "warp-test/2", 3000); err != nil {
|
||||
t.Fatalf("TouchDevice: %v", err)
|
||||
}
|
||||
if stored, err = users.Device(ctx, "alice", "DEV1"); err != nil {
|
||||
t.Fatal(err)
|
||||
} else if stored.LastSeenMS != 3000 || stored.LastSeenIP != "10.0.0.2" {
|
||||
t.Errorf("TouchDevice did not record the visit: %+v", stored)
|
||||
}
|
||||
|
||||
if err := users.SetDeviceDisplayName(ctx, "alice", "DEV1", "renamed"); err != nil {
|
||||
t.Fatalf("SetDeviceDisplayName: %v", err)
|
||||
}
|
||||
if err := users.SetDeviceDisplayName(ctx, "alice", "GONE", "x"); !errors.Is(err, storage.ErrNoSuchDevice) {
|
||||
t.Errorf("renaming an unknown device = %v, want ErrNoSuchDevice", err)
|
||||
}
|
||||
|
||||
second := storage.NewSession{
|
||||
Localpart: "alice",
|
||||
DeviceID: "DEV2",
|
||||
CreatedAtMS: 4000,
|
||||
AccessTokenHash: "hash-c",
|
||||
}
|
||||
if _, err := users.CreateSession(ctx, second); err != nil {
|
||||
t.Fatalf("CreateSession (second device): %v", err)
|
||||
}
|
||||
devices, err := users.Devices(ctx, "alice")
|
||||
if err != nil {
|
||||
t.Fatalf("Devices: %v", err)
|
||||
}
|
||||
if len(devices) != 2 || devices[0].ID != "DEV1" || devices[1].ID != "DEV2" {
|
||||
t.Errorf("Devices = %v", devices)
|
||||
}
|
||||
|
||||
deleted, err := users.DeleteDevices(ctx, "alice", []string{"DEV1", "NEVER-EXISTED"})
|
||||
if err != nil {
|
||||
t.Fatalf("DeleteDevices: %v", err)
|
||||
}
|
||||
if deleted != 1 {
|
||||
t.Errorf("DeleteDevices reported %d, want 1", deleted)
|
||||
}
|
||||
if _, err := users.TokenInfo(ctx, "hash-b"); !errors.Is(err, storage.ErrNoSuchToken) {
|
||||
t.Error("deleting a device must revoke its access tokens")
|
||||
}
|
||||
|
||||
deleted, err = users.DeleteAllDevices(ctx, "alice")
|
||||
if err != nil {
|
||||
t.Fatalf("DeleteAllDevices: %v", err)
|
||||
}
|
||||
if deleted != 1 {
|
||||
t.Errorf("DeleteAllDevices reported %d, want 1", deleted)
|
||||
}
|
||||
if _, err := users.TokenInfo(ctx, "hash-c"); !errors.Is(err, storage.ErrNoSuchToken) {
|
||||
t.Error("logout/all must revoke every token")
|
||||
}
|
||||
if devices, err = users.Devices(ctx, "alice"); err != nil {
|
||||
t.Fatal(err)
|
||||
} else if len(devices) != 0 {
|
||||
t.Errorf("devices survived DeleteAllDevices: %v", devices)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserRefreshTokens(t *testing.T) {
|
||||
for _, driver := range testDrivers {
|
||||
t.Run(driver, func(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
users := migratedTestDB(t, driver).Users()
|
||||
if _, err := users.CreateAccount(ctx, newAccount("alice")); err != nil {
|
||||
t.Fatalf("CreateAccount: %v", err)
|
||||
}
|
||||
if _, err := users.CreateSession(ctx, storage.NewSession{
|
||||
Localpart: "alice",
|
||||
DeviceID: "DEV1",
|
||||
CreatedAtMS: 1000,
|
||||
AccessTokenHash: "access-1",
|
||||
AccessTokenExpiresAtMS: 5000,
|
||||
RefreshTokenHash: "refresh-1",
|
||||
RefreshTokenExpiresAtMS: 0,
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateSession: %v", err)
|
||||
}
|
||||
|
||||
info, err := users.RefreshSession(ctx, storage.RefreshedSession{
|
||||
RefreshTokenHash: "refresh-1",
|
||||
NewAccessTokenHash: "access-2",
|
||||
AccessTokenExpiresAtMS: 9000,
|
||||
NewRefreshTokenHash: "refresh-2",
|
||||
RefreshedAtMS: 4000,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("RefreshSession: %v", err)
|
||||
}
|
||||
if info.Localpart != "alice" || info.DeviceID != "DEV1" {
|
||||
t.Errorf("RefreshSession returned %+v", info)
|
||||
}
|
||||
|
||||
// Both halves of the old pair are gone: a replayed refresh token
|
||||
// is the signal that one of the two copies is a thief.
|
||||
if _, err := users.RefreshSession(ctx, storage.RefreshedSession{
|
||||
RefreshTokenHash: "refresh-1",
|
||||
NewAccessTokenHash: "access-3",
|
||||
NewRefreshTokenHash: "refresh-3",
|
||||
RefreshedAtMS: 4100,
|
||||
}); !errors.Is(err, storage.ErrNoSuchToken) {
|
||||
t.Errorf("replaying a spent refresh token = %v, want ErrNoSuchToken", err)
|
||||
}
|
||||
if _, err := users.TokenInfo(ctx, "access-1"); !errors.Is(err, storage.ErrNoSuchToken) {
|
||||
t.Error("refreshing must invalidate the previous access token")
|
||||
}
|
||||
if info, err = users.TokenInfo(ctx, "access-2"); err != nil {
|
||||
t.Fatalf("the refreshed access token does not work: %v", err)
|
||||
} else if info.ExpiresAtMS != 9000 {
|
||||
t.Errorf("expires_at_ms = %d, want 9000", info.ExpiresAtMS)
|
||||
}
|
||||
|
||||
if _, err := users.RefreshSession(ctx, storage.RefreshedSession{
|
||||
RefreshTokenHash: "nonexistent",
|
||||
NewAccessTokenHash: "access-4",
|
||||
RefreshedAtMS: 4200,
|
||||
}); !errors.Is(err, storage.ErrNoSuchToken) {
|
||||
t.Errorf("unknown refresh token = %v, want ErrNoSuchToken", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserRefreshTokenExpiry(t *testing.T) {
|
||||
for _, driver := range testDrivers {
|
||||
t.Run(driver, func(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
users := migratedTestDB(t, driver).Users()
|
||||
if _, err := users.CreateAccount(ctx, newAccount("alice")); err != nil {
|
||||
t.Fatalf("CreateAccount: %v", err)
|
||||
}
|
||||
if _, err := users.CreateSession(ctx, storage.NewSession{
|
||||
Localpart: "alice",
|
||||
DeviceID: "DEV1",
|
||||
CreatedAtMS: 1000,
|
||||
AccessTokenHash: "access-1",
|
||||
RefreshTokenHash: "refresh-1",
|
||||
RefreshTokenExpiresAtMS: 2000,
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateSession: %v", err)
|
||||
}
|
||||
|
||||
if _, err := users.RefreshSession(ctx, storage.RefreshedSession{
|
||||
RefreshTokenHash: "refresh-1",
|
||||
NewAccessTokenHash: "access-2",
|
||||
RefreshedAtMS: 2001,
|
||||
}); !errors.Is(err, storage.ErrNoSuchToken) {
|
||||
t.Errorf("expired refresh token = %v, want ErrNoSuchToken", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserAccountData(t *testing.T) {
|
||||
for _, driver := range testDrivers {
|
||||
t.Run(driver, func(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
users := migratedTestDB(t, driver).Users()
|
||||
if _, err := users.CreateAccount(ctx, newAccount("alice")); err != nil {
|
||||
t.Fatalf("CreateAccount: %v", err)
|
||||
}
|
||||
|
||||
if _, err := users.AccountData(ctx, "alice", "", "m.push_rules"); !errors.Is(err, storage.ErrNoSuchAccountData) {
|
||||
t.Errorf("unset account data = %v, want ErrNoSuchAccountData", err)
|
||||
}
|
||||
|
||||
global := []byte(`{"theme":"dark"}`)
|
||||
if err := users.SetAccountData(ctx, "alice", "", "im.warp.prefs", global); err != nil {
|
||||
t.Fatalf("SetAccountData: %v", err)
|
||||
}
|
||||
got, err := users.AccountData(ctx, "alice", "", "im.warp.prefs")
|
||||
if err != nil {
|
||||
t.Fatalf("AccountData: %v", err)
|
||||
}
|
||||
if string(got) != string(global) {
|
||||
t.Errorf("account data came back as %s, want %s", got, global)
|
||||
}
|
||||
|
||||
// Overwriting replaces rather than conflicting.
|
||||
if err := users.SetAccountData(ctx, "alice", "", "im.warp.prefs", []byte(`{"theme":"light"}`)); err != nil {
|
||||
t.Fatalf("SetAccountData (overwrite): %v", err)
|
||||
}
|
||||
if got, err = users.AccountData(ctx, "alice", "", "im.warp.prefs"); err != nil {
|
||||
t.Fatal(err)
|
||||
} else if string(got) != `{"theme":"light"}` {
|
||||
t.Errorf("overwritten account data = %s", got)
|
||||
}
|
||||
|
||||
// Room account data is a separate key space from global data of
|
||||
// the same type.
|
||||
if err := users.SetAccountData(ctx, "alice", "!room:warp.example", "im.warp.prefs", []byte(`{"muted":true}`)); err != nil {
|
||||
t.Fatalf("SetAccountData (room): %v", err)
|
||||
}
|
||||
if got, err = users.AccountData(ctx, "alice", "!room:warp.example", "im.warp.prefs"); err != nil {
|
||||
t.Fatal(err)
|
||||
} else if string(got) != `{"muted":true}` {
|
||||
t.Errorf("room account data = %s", got)
|
||||
}
|
||||
if got, err = users.AccountData(ctx, "alice", "", "im.warp.prefs"); err != nil {
|
||||
t.Fatal(err)
|
||||
} else if string(got) != `{"theme":"light"}` {
|
||||
t.Errorf("room account data overwrote the global value: %s", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user