480 lines
19 KiB
Go
480 lines
19 KiB
Go
package pgstore
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"errors"
|
|
"strings"
|
|
|
|
"net/url"
|
|
"os"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgconn"
|
|
|
|
"textmachine/platform/internal/auth"
|
|
)
|
|
|
|
// The database-backed battery. It runs against TM_PLATFORM_TEST_DSN and skips loudly without it —
|
|
// `make check` names the skip, because an invisible skip reads as coverage.
|
|
//
|
|
// Each run gets its OWN database, created and dropped here: a test that leaves rows behind passes
|
|
// once and then lies.
|
|
func testDB(t testing.TB) (*Store, context.Context) {
|
|
t.Helper()
|
|
s, ctx, _ := testDBWithDSN(t)
|
|
return s, ctx
|
|
}
|
|
|
|
func testDBWithDSN(t testing.TB) (*Store, context.Context, string) {
|
|
t.Helper()
|
|
admin := os.Getenv("TM_PLATFORM_TEST_DSN")
|
|
if admin == "" {
|
|
t.Skip("TM_PLATFORM_TEST_DSN not set: schema and session tests need a live Postgres")
|
|
}
|
|
ctx := t.Context()
|
|
|
|
var suffix [6]byte
|
|
if _, err := rand.Read(suffix[:]); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
name := "tm_platform_test_" + hex.EncodeToString(suffix[:])
|
|
|
|
adminConn, err := pgx.Connect(ctx, admin)
|
|
if err != nil {
|
|
t.Fatalf("connect: %v", err)
|
|
}
|
|
if _, err := adminConn.Exec(ctx, "create database "+pgx.Identifier{name}.Sanitize()); err != nil {
|
|
adminConn.Close(ctx)
|
|
t.Skipf("cannot create a scratch database (%v): grant CREATEDB or point the DSN at one", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
dropCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
|
defer cancel()
|
|
_, _ = adminConn.Exec(dropCtx, "drop database if exists "+pgx.Identifier{name}.Sanitize()+" with (force)")
|
|
adminConn.Close(dropCtx)
|
|
})
|
|
|
|
dsn := swapDatabase(t, admin, name)
|
|
if err := Migrate(ctx, dsn); err != nil {
|
|
t.Fatalf("migrate: %v", err)
|
|
}
|
|
// Twice, because a rollout re-runs it on every replica.
|
|
if err := Migrate(ctx, dsn); err != nil {
|
|
t.Fatalf("second migrate must be a no-op: %v", err)
|
|
}
|
|
s, err := Open(ctx, dsn)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(s.Close)
|
|
if err := s.Ping(ctx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return s, ctx, dsn
|
|
}
|
|
|
|
func swapDatabase(t testing.TB, dsn, name string) string {
|
|
t.Helper()
|
|
u, err := url.Parse(dsn)
|
|
if err != nil {
|
|
t.Fatalf("TM_PLATFORM_TEST_DSN must be a URL: %v", err)
|
|
}
|
|
u.Path = "/" + name
|
|
return u.String()
|
|
}
|
|
|
|
func TestSessionLifecycle(t *testing.T) {
|
|
s, ctx := testDB(t)
|
|
now := time.Now().UTC().Truncate(time.Millisecond)
|
|
seedUser(t, s, ctx, "u1")
|
|
|
|
token := auth.NewToken()
|
|
digest := auth.Digest(token)
|
|
if err := s.CreateSession(ctx, digest, "u1", now, time.Hour, 24*time.Hour); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
got, err := s.Lookup(ctx, digest, now)
|
|
if err != nil {
|
|
t.Fatalf("lookup: %v", err)
|
|
}
|
|
if got.UserID != "u1" {
|
|
t.Fatalf("session = %+v", got)
|
|
}
|
|
|
|
// Expiry is a clause of the query, not a caller's duty.
|
|
if _, err := s.Lookup(ctx, digest, now.Add(2*time.Hour)); !errors.Is(err, auth.ErrNoSession) {
|
|
t.Fatalf("expired idle window: %v", err)
|
|
}
|
|
// Sliding never outlives the absolute deadline.
|
|
if err := s.Touch(ctx, digest, now.Add(30*time.Minute), 48*time.Hour); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
slid, err := s.Lookup(ctx, digest, now.Add(30*time.Minute))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if slid.IdleExpiresAt.After(slid.AbsoluteExpiresAt) {
|
|
t.Fatalf("idle %s outlived absolute %s", slid.IdleExpiresAt, slid.AbsoluteExpiresAt)
|
|
}
|
|
|
|
if err := s.RevokeSession(ctx, digest, now); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := s.Lookup(ctx, digest, now); !errors.Is(err, auth.ErrNoSession) {
|
|
t.Fatalf("revoked session still resolves: %v", err)
|
|
}
|
|
|
|
// The sweep also takes revoked rows: a revoked session is the one a compromised account most
|
|
// wants gone, and it used to sit until its absolute expiry ninety days later.
|
|
n, err := s.SweepSessions(ctx, now)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if n != 1 {
|
|
t.Fatalf("sweep removed %d rows, want the revoked one", n)
|
|
}
|
|
}
|
|
|
|
// PD-1. What the database holds must be the HASH of the credential, and the property has to be
|
|
// checked by something other than the function that produces it: asserting through auth.Digest is
|
|
// self-consistent and survives a Digest that returns the plaintext. The oracle here is
|
|
// crypto/sha256 in the test, plus a search of the whole rendered row for the token itself.
|
|
// Mutation caught: `func Digest(t string) []byte { return []byte(t) }`.
|
|
func TestStoredCredentialIsAHashNotTheToken(t *testing.T) {
|
|
s, ctx := testDB(t)
|
|
seedUser(t, s, ctx, "u1")
|
|
now := time.Now().UTC()
|
|
|
|
token := auth.NewToken()
|
|
if err := s.CreateSession(ctx, auth.Digest(token), "u1", now, time.Hour, 24*time.Hour); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
var stored []byte
|
|
if err := s.pool.QueryRow(ctx, `select token_sha256 from sessions`).Scan(&stored); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
want := sha256.Sum256([]byte(token))
|
|
if !bytes.Equal(stored, want[:]) {
|
|
t.Fatalf("stored credential is not SHA-256 of the token: %x", stored)
|
|
}
|
|
|
|
// Broader than the column: any future column that copied the token in would fail this too.
|
|
var row string
|
|
if err := s.pool.QueryRow(ctx, `select sessions::text from sessions`).Scan(&row); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if strings.Contains(row, token) {
|
|
t.Fatal("the plaintext token is present in the sessions row")
|
|
}
|
|
|
|
// And the credential still resolves, so the two assertions above are about a real session.
|
|
if _, err := s.Lookup(ctx, auth.Digest(token), now); err != nil {
|
|
t.Fatalf("lookup: %v", err)
|
|
}
|
|
}
|
|
|
|
// PD-4. Touch is only reachable after a successful Lookup, so this is depth: a query able to
|
|
// resurrect an idle-expired session is not one to leave for the next caller.
|
|
// Mutation caught: dropping `idle_expires_at > $2` from Touch's WHERE.
|
|
func TestTouchCannotResurrectAnIdleExpiredSession(t *testing.T) {
|
|
s, ctx := testDB(t)
|
|
seedUser(t, s, ctx, "u1")
|
|
now := time.Now().UTC()
|
|
digest := auth.Digest(auth.NewToken())
|
|
if err := s.CreateSession(ctx, digest, "u1", now, time.Hour, 24*time.Hour); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
later := now.Add(2 * time.Hour) // past the idle window, inside the absolute one
|
|
if err := s.Touch(ctx, digest, later, time.Hour); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := s.Lookup(ctx, digest, later); !errors.Is(err, auth.ErrNoSession) {
|
|
t.Fatalf("an idle-expired session came back to life: %v", err)
|
|
}
|
|
}
|
|
|
|
// The rollback exists and runs. A down path that has never been executed is a claim, not a path.
|
|
// ⚠ The rollback is exercised with DATA in the tables whose VOCABULARY the migration rewrites, and
|
|
// that is the whole test: an empty database proves the DDL is well-formed, not that a deployment can
|
|
// actually roll back. goose runs a migration in one transaction, so the first violated CHECK aborts
|
|
// the entire rollback — and the three vocabulary rewrites of 00016 had no data-fixing updates until
|
|
// a reviewer reproduced exactly that abort against a live Postgres.
|
|
func TestARollbackSurvivesTheDataTheNewVocabulariesWrote(t *testing.T) {
|
|
s, ctx, dsn := testDBWithDSN(t)
|
|
exec(t, s, ctx, `insert into users (id) values ('u1')`)
|
|
exec(t, s, ctx, `insert into books (id, owner_id, title, source_lang, target_lang, status, workdir, engine_book_id)
|
|
values ('bk1','u1','蛊真人','zh','ru','not_started','/srv/books/bk1','bk1')`)
|
|
// One ordinary row per table, written exactly as the new code writes them.
|
|
exec(t, s, ctx, `insert into bank_terms (id, book_id, src, dst, status, origin)
|
|
values ('t1','bk1','方源','Фан Юань','proposed','found')`)
|
|
exec(t, s, ctx, `insert into bank_decisions (book_id, term_id, action, dst, decided_by)
|
|
values ('bk1','t1','approve','Фан Юань','u1')`)
|
|
// 00018's own rows: a chapter (the deferred uniqueness) and an idempotency key (the primary key
|
|
// that moved onto a digest). Without them the rollback was tested over a schema nobody had used.
|
|
exec(t, s, ctx, `insert into chapters (id, book_id, number, units_total)
|
|
values ('ch1','bk1',1,2)`)
|
|
exec(t, s, ctx, `insert into idempotency_keys (user_id, method, path, path_sha256, key, request_sha256, claim_token)
|
|
values ('u1','POST','/v0/books', sha256(convert_to('/v0/books','UTF8')), 'k1', '\x00'::bytea, 'clm_test')`)
|
|
p, closeProvider, err := newProvider(dsn)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer closeProvider()
|
|
// Down to version 5 and no further. 00005's own down path restores `email NOT NULL` and the
|
|
// unique index, and both are violated by rows production writes, so rolling to zero here would
|
|
// test that known limit instead of this migration.
|
|
//
|
|
// ⚠ The DOCUMENTED floor of a real rollback is 15, not 5 (`deploy/README.md`, "Откат релиза: не
|
|
// ниже версии 15"): 00015's down path narrows `runs_paused_reason_check` back to one value while
|
|
// production writes three. That floor is about DATA, not schema — it bites only a base that has
|
|
// held such a pause — so this base rolls past it honestly, and this test passing is not evidence
|
|
// against the documented floor.
|
|
if _, err := p.DownTo(ctx, 5); err != nil {
|
|
t.Fatalf("rolling back a database that holds real rows: %v", err)
|
|
}
|
|
if _, err := p.Up(ctx); err != nil {
|
|
t.Fatalf("re-applying after the rollback: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestMigrationsRollBackAndReapply(t *testing.T) {
|
|
s, ctx, dsn := testDBWithDSN(t) // migrated up, twice, by the helper
|
|
|
|
p, closeDB, err := newProvider(dsn)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer closeDB()
|
|
|
|
if _, err := p.DownTo(ctx, 0); err != nil {
|
|
t.Fatalf("down: %v", err)
|
|
}
|
|
var exists bool
|
|
if err := s.pool.QueryRow(ctx, `select to_regclass('public.sessions') is not null`).Scan(&exists); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if exists {
|
|
t.Fatal("sessions survived a full rollback")
|
|
}
|
|
if _, err := p.Up(ctx); err != nil {
|
|
t.Fatalf("re-apply: %v", err)
|
|
}
|
|
if err := s.pool.QueryRow(ctx, `select to_regclass('public.sessions') is not null`).Scan(&exists); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !exists {
|
|
t.Fatal("re-apply did not restore the schema")
|
|
}
|
|
}
|
|
|
|
func TestUnknownTokenIsIndistinguishable(t *testing.T) {
|
|
s, ctx := testDB(t)
|
|
if _, err := s.Lookup(ctx, auth.Digest("never-issued"), time.Now()); !errors.Is(err, auth.ErrNoSession) {
|
|
t.Fatalf("want ErrNoSession, got %v", err)
|
|
}
|
|
}
|
|
|
|
// The read-model's derivation rules are DDL, so a materializer bug fails at the write instead of
|
|
// reaching a reader. These assert the constraints actually fire.
|
|
func TestReadModelConstraints(t *testing.T) {
|
|
s, ctx := testDB(t)
|
|
seedUser(t, s, ctx, "u1")
|
|
exec(t, s, ctx, `insert into books (id, owner_id, title, source_lang, target_lang, status, workdir, engine_book_id)
|
|
values ('bk1','u1','蛊真人','zh','ru','translating','/srv/books/bk1','gzr')`)
|
|
exec(t, s, ctx, `insert into chapters (id, book_id, number) values ('ch1','bk1',1)`)
|
|
|
|
t.Run("translated needs text", func(t *testing.T) {
|
|
assertViolation(t, s, ctx, "units_translated_has_text",
|
|
`insert into units (id, chapter_id, ordinal, source, target, state)
|
|
values ('un1','ch1',1,'第一节','','translated')`)
|
|
})
|
|
t.Run("withheld carries none", func(t *testing.T) {
|
|
assertViolation(t, s, ctx, "units_unshipped_is_empty",
|
|
`insert into units (id, chapter_id, ordinal, source, target, state)
|
|
values ('un2','ch1',2,'第二节','перевод','withheld')`)
|
|
})
|
|
// ⚠ The vocabularies below are the CONTRACT's, not the engine's: 0.3.0 renamed both axes so that
|
|
// a wave name (`draft`), a stage name (`mined`) and a pair-specific word (`ruby`) stop crossing
|
|
// this seam, and the mapping is done where the sidecar is read. The DDL is what makes a path that
|
|
// forgot to translate fail here rather than on a client's screen.
|
|
t.Run("an approval needs a rendering", func(t *testing.T) {
|
|
exec(t, s, ctx, `insert into bank_terms (id, book_id, src, status, origin) values ('t1','bk1','方源','in_progress','found')`)
|
|
assertViolation(t, s, ctx, "bank_decisions_approve_has_dst",
|
|
`insert into bank_decisions (book_id, term_id, action, dst, decided_by) values ('bk1','t1','approve','','u1')`)
|
|
})
|
|
t.Run("the engine's own words are refused", func(t *testing.T) {
|
|
assertViolation(t, s, ctx, "bank_terms_origin_check",
|
|
`insert into bank_terms (id, book_id, src, status, origin) values ('t9','bk1','源','proposed','mined')`)
|
|
assertViolation(t, s, ctx, "bank_terms_status_check",
|
|
`insert into bank_terms (id, book_id, src, status, origin) values ('t8','bk1','源','draft','found')`)
|
|
})
|
|
t.Run("kind may be absent but not invented", func(t *testing.T) {
|
|
// A candidate that is neither a name nor a place carries no kind, and the row is still
|
|
// signable — the contract forbids inventing one for it.
|
|
exec(t, s, ctx, `insert into bank_terms (id, book_id, src, kind, status, origin) values ('t2','bk1','李',null,'in_progress','annotated')`)
|
|
assertViolation(t, s, ctx, "bank_terms_kind_check",
|
|
`insert into bank_terms (id, book_id, src, kind, status, origin) values ('t3','bk1','王','org','in_progress','annotated')`)
|
|
})
|
|
t.Run("one live run per book", func(t *testing.T) {
|
|
exec(t, s, ctx, `insert into runs (id, book_id, status, verify_bank) values ('r1','bk1','translating',true)`)
|
|
assertViolation(t, s, ctx, "runs_one_live_per_book",
|
|
`insert into runs (id, book_id, status, verify_bank) values ('r2','bk1','translating',false)`)
|
|
})
|
|
t.Run("the paused status exists", func(t *testing.T) {
|
|
// D39.100: a ceiling stop is the eleventh book status, and it is not `failed`.
|
|
exec(t, s, ctx, `update books set status='paused' where id='bk1'`)
|
|
assertViolation(t, s, ctx, "books_status_check", `update books set status='exhausted' where id='bk1'`)
|
|
})
|
|
}
|
|
|
|
func seedUser(t testing.TB, s *Store, ctx context.Context, id string) {
|
|
t.Helper()
|
|
exec(t, s, ctx, `insert into users (id, email) values ($1, $1 || '@example.org')`, id)
|
|
}
|
|
|
|
func exec(t testing.TB, s *Store, ctx context.Context, sql string, args ...any) {
|
|
t.Helper()
|
|
if _, err := s.pool.Exec(ctx, sql, args...); err != nil {
|
|
t.Fatalf("exec %s: %v", sql, err)
|
|
}
|
|
}
|
|
|
|
func assertViolation(t *testing.T, s *Store, ctx context.Context, constraint, sql string) {
|
|
t.Helper()
|
|
_, err := s.pool.Exec(ctx, sql)
|
|
var pgErr *pgconn.PgError
|
|
if !errors.As(err, &pgErr) {
|
|
t.Fatalf("want a constraint violation, got %v", err)
|
|
}
|
|
if pgErr.ConstraintName != constraint {
|
|
t.Fatalf("violated %q, want %q", pgErr.ConstraintName, constraint)
|
|
}
|
|
}
|
|
|
|
// The upgrade path from an already-released schema. This is the shape of the defect that reusing a
|
|
// migration number produced: goose records only the NUMBER, so a database that stopped at version 3
|
|
// accepted "migrations applied" and got none of the tables the new release added.
|
|
func TestDatabaseAtAnOlderReleaseCatchesUp(t *testing.T) {
|
|
_, ctx, dsn := testDBWithDSN(t)
|
|
p, closeDB, err := newProvider(dsn)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer closeDB()
|
|
|
|
// Back to the previous release, then forward with the current set — a deployment, not a fresh
|
|
// install.
|
|
if _, err := p.DownTo(ctx, 3); err != nil {
|
|
t.Fatalf("down to the previous release: %v", err)
|
|
}
|
|
if _, err := p.Up(ctx); err != nil {
|
|
t.Fatalf("catch up: %v", err)
|
|
}
|
|
|
|
after, err := Open(ctx, dsn)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer after.Close()
|
|
for _, table := range []string{"identities", "auth_states", "login_events", "credit_ledger", "account_balances", "reservations"} {
|
|
var exists bool
|
|
if err := after.pool.QueryRow(ctx,
|
|
`select to_regclass('public.' || $1) is not null`, table).Scan(&exists); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !exists {
|
|
t.Fatalf("%s is missing after catching up: the release reported success and did nothing", table)
|
|
}
|
|
}
|
|
// And the draft that the credit model replaced is gone rather than orphaned.
|
|
var stale bool
|
|
if err := after.pool.QueryRow(ctx,
|
|
`select to_regclass('public.usage_windows') is not null`).Scan(&stale); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if stale {
|
|
t.Fatal("usage_windows survived the upgrade")
|
|
}
|
|
}
|
|
|
|
// Readiness has to mean "this database is the one this build was made for", not "something answered
|
|
// on port 5432". Migrate is off by default and the deploy notes make migrating a separate step, so
|
|
// "process up, schema not applied" is the ordinary middle of a rollout — and an instance that calls
|
|
// itself ready there fails every query it then serves. Found by review.
|
|
// Mutation caught: readiness reduced to Ping; comparing the wrong way round.
|
|
func TestReadinessRefusesADatabaseWithoutTheSchema(t *testing.T) {
|
|
s, ctx := testDB(t)
|
|
|
|
if err := s.Ready(ctx); err != nil {
|
|
t.Fatalf("a migrated database must be ready: %v", err)
|
|
}
|
|
|
|
want, err := latestMigration()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// Wind the recorded version back one step without touching the tables: the shape of "the binary
|
|
// carries a migration this database has not seen".
|
|
exec(t, s, ctx, `delete from goose_db_version where version_id = $1`, want)
|
|
err = s.Ready(ctx)
|
|
if !errors.Is(err, ErrSchemaBehind) {
|
|
t.Fatalf("a database behind this build reported ready: %v", err)
|
|
}
|
|
|
|
// And a database that has never been migrated at all — no version table.
|
|
exec(t, s, ctx, `drop table goose_db_version`)
|
|
if err := s.Ready(ctx); !errors.Is(err, ErrSchemaBehind) {
|
|
t.Fatalf("an unmigrated database reported ready: %v", err)
|
|
}
|
|
// Reachability alone still says yes, which is exactly why it is not the readiness question.
|
|
if err := s.Ping(ctx); err != nil {
|
|
t.Fatalf("ping should still succeed: %v", err)
|
|
}
|
|
}
|
|
|
|
// The pool sizes an operator writes into the DSN must survive, and "the operator said nothing" must
|
|
// be read from the DSN rather than inferred from the value pgx happened to pick. Found by review:
|
|
// the previous form compared against pgxpool's own default, which is indistinguishable from an
|
|
// operator choosing that same number, and pgx's min-conns default of 0 made an explicit 0 impossible.
|
|
// Mutation caught: going back to a value comparison or a substring search.
|
|
func TestExplicitPoolSizesInTheDSNSurvive(t *testing.T) {
|
|
for name, tc := range map[string]struct {
|
|
dsn string
|
|
wantMax, wantMin int32
|
|
}{
|
|
"nothing said, ours apply": {
|
|
"postgres://u@h:5432/db?sslmode=disable", defaultMaxConns, defaultMinConns},
|
|
"url form, both set": {
|
|
"postgres://u@h:5432/db?pool_max_conns=8&pool_min_conns=0&sslmode=disable", 8, 0},
|
|
"keyword form, both set": {
|
|
"host=h user=u dbname=db pool_max_conns=8 pool_min_conns=0", 8, 0},
|
|
// The case that broke the substring test it replaced.
|
|
"a password that merely contains the key name": {
|
|
"postgres://u:pool_max_conns%3D99@h:5432/db?sslmode=disable", defaultMaxConns, defaultMinConns},
|
|
"keyword form with a quoted password containing the key name": {
|
|
`host=h user=u password='pool_max_conns=99 x' dbname=db`, defaultMaxConns, defaultMinConns},
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
s, err := Open(t.Context(), tc.dsn)
|
|
if err != nil {
|
|
t.Fatalf("open: %v", err)
|
|
}
|
|
defer s.Close()
|
|
if got := s.pool.Config().MaxConns; got != tc.wantMax {
|
|
t.Errorf("MaxConns = %d, want %d", got, tc.wantMax)
|
|
}
|
|
if got := s.pool.Config().MinConns; got != tc.wantMin {
|
|
t.Errorf("MinConns = %d, want %d", got, tc.wantMin)
|
|
}
|
|
})
|
|
}
|
|
}
|