133728e barerepo 1mo
1
package store
133728e barerepo 1mo
3
import (
133728e barerepo 1mo
4
"context"
133728e barerepo 1mo
5
"errors"
133728e barerepo 1mo
6
"path/filepath"
133728e barerepo 1mo
7
"regexp"
133728e barerepo 1mo
8
"sort"
133728e barerepo 1mo
9
"strings"
133728e barerepo 1mo
10
"testing"
133728e barerepo 1mo
11
"time"
133728e barerepo 1mo
13
"github.com/barerepo/server/internal/config"
133728e barerepo 1mo
16
// A real ed25519 public key, and the private key a user will paste by mistake.
133728e barerepo 1mo
17
const (
133728e barerepo 1mo
18
keyA = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIH8fK2q0mR4vXeN1pLzT9wBcJdSgYo3Ea7kVnQxMuP2r laptop"
133728e barerepo 1mo
19
keyB = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDXWNJHVvNjWLqL0YB2Cbp3ObGZlAqNKvGD5f8ZKr8Kx uproar"
133728e barerepo 1mo
20
priv = "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAA\n-----END OPENSSH PRIVATE KEY-----"
133728e barerepo 1mo
23
// The tests run on SQLite for a fresh database each, and TestMigrationsAgree guards PostgreSQL.
133728e barerepo 1mo
24
func open(t *testing.T) *DB {
133728e barerepo 1mo
25
t.Helper()
133728e barerepo 1mo
26
db, err := Open(context.Background(), "sqlite://"+filepath.Join(t.TempDir(), "barerepo.db"))
133728e barerepo 1mo
27
if err != nil {
133728e barerepo 1mo
28
t.Fatalf("Open: %v", err)
133728e barerepo 1mo
30
t.Cleanup(func() { db.Close() })
133728e barerepo 1mo
31
return db
133728e barerepo 1mo
34
func TestAccountLifecycle(t *testing.T) {
133728e barerepo 1mo
35
ctx := context.Background()
133728e barerepo 1mo
36
db := open(t)
133728e barerepo 1mo
38
if _, err := db.CreateAccount(ctx, "john", keyA, false); err != nil {
133728e barerepo 1mo
39
t.Fatalf("CreateAccount: %v", err)
133728e barerepo 1mo
41
if _, err := db.CreateAccount(ctx, "john", keyB, false); !errors.Is(err, ErrTaken) {
133728e barerepo 1mo
42
t.Errorf("second john: got %v, want ErrTaken", err)
133728e barerepo 1mo
44
if _, err := db.CreateAccount(ctx, "lisa", keyA, false); !errors.Is(err, ErrTaken) {
133728e barerepo 1mo
45
t.Errorf("reused key: got %v, want ErrTaken", err)
133728e barerepo 1mo
47
// Chapter 42.5: a name that shadows a route is not an account.
133728e barerepo 1mo
48
if _, err := db.CreateAccount(ctx, "inbox", keyB, false); err == nil {
133728e barerepo 1mo
49
t.Error("account named inbox was accepted")
133728e barerepo 1mo
51
// Chapter 27 and 45.4: the private key paste.
133728e barerepo 1mo
52
if _, err := db.CreateAccount(ctx, "lisa", priv, false); err == nil ||
133728e barerepo 1mo
53
!strings.Contains(err.Error(), "private key") {
133728e barerepo 1mo
54
t.Errorf("private key paste: got %v, want a message naming it", err)
133728e barerepo 1mo
57
a, err := db.Account(ctx, "john")
133728e barerepo 1mo
58
if err != nil || a.Name != "john" || a.Admin {
133728e barerepo 1mo
59
t.Fatalf("Account: %v %+v", err, a)
133728e barerepo 1mo
61
if _, err := db.Account(ctx, "nobody"); !errors.Is(err, ErrNotFound) {
133728e barerepo 1mo
62
t.Errorf("missing account: got %v, want ErrNotFound", err)
133728e barerepo 1mo
65
// Chapter 10: an account with one key must not be able to lose it.
133728e barerepo 1mo
66
keys, _ := db.Keys(ctx, "john")
133728e barerepo 1mo
67
if len(keys) != 1 {
133728e barerepo 1mo
68
t.Fatalf("Keys: got %d, want 1", len(keys))
133728e barerepo 1mo
70
if err := db.DeleteKey(ctx, "john", keys[0].ID); err == nil {
133728e barerepo 1mo
71
t.Error("deleting the only key was allowed")
133728e barerepo 1mo
73
if _, err := db.AddKey(ctx, "john", keyB); err != nil {
133728e barerepo 1mo
74
t.Fatalf("AddKey: %v", err)
133728e barerepo 1mo
76
if err := db.DeleteKey(ctx, "john", keys[0].ID); err != nil {
133728e barerepo 1mo
77
t.Errorf("DeleteKey with two keys: %v", err)
133728e barerepo 1mo
79
if keys, _ = db.Keys(ctx, "john"); len(keys) != 1 {
133728e barerepo 1mo
80
t.Errorf("after delete: got %d keys, want 1", len(keys))
133728e barerepo 1mo
84
func TestMigrateIsIdempotent(t *testing.T) {
133728e barerepo 1mo
85
ctx := context.Background()
133728e barerepo 1mo
86
db := open(t)
133728e barerepo 1mo
87
if err := db.migrate(ctx); err != nil {
133728e barerepo 1mo
88
t.Fatalf("second migrate: %v", err)
133728e barerepo 1mo
90
var n int
133728e barerepo 1mo
91
if err := db.QueryRowContext(ctx,
133728e barerepo 1mo
92
`SELECT COUNT(*) FROM schema_migrations`).Scan(&n); err != nil {
133728e barerepo 1mo
93
t.Fatal(err)
133728e barerepo 1mo
95
if n != len(migrations) {
133728e barerepo 1mo
96
t.Errorf("applied %d migrations, want %d", n, len(migrations))
133728e barerepo 1mo
100
// TestMigrationsAgree is what stops one hand-written dialect gaining a column alone.
133728e barerepo 1mo
101
func TestMigrationsAgree(t *testing.T) {
133728e barerepo 1mo
102
for i, m := range migrations {
133728e barerepo 1mo
103
s, p := shape(m.sqlite), shape(m.postgres)
133728e barerepo 1mo
104
if len(s) != len(p) {
133728e barerepo 1mo
105
t.Fatalf("migration %d: sqlite has %v, postgres has %v", i+1, keysOf(s), keysOf(p))
133728e barerepo 1mo
106
}
133728e barerepo 1mo
107
for table, cols := range s {
133728e barerepo 1mo
108
other, ok := p[table]
133728e barerepo 1mo
109
if !ok {
133728e barerepo 1mo
110
t.Errorf("migration %d: postgres is missing table %s", i+1, table)
133728e barerepo 1mo
111
continue
133728e barerepo 1mo
112
}
133728e barerepo 1mo
113
if strings.Join(cols, ",") != strings.Join(other, ",") {
133728e barerepo 1mo
114
t.Errorf("migration %d: table %s\n sqlite: %v\n postgres: %v",
133728e barerepo 1mo
115
i+1, table, cols, other)
133728e barerepo 1mo
116
}
133728e barerepo 1mo
117
}
133728e barerepo 1mo
118
}
133728e barerepo 1mo
121
var (
133728e barerepo 1mo
122
tableRe = regexp.MustCompile(`(?is)CREATE TABLE (\w+) \((.*?)\);`)
133728e barerepo 1mo
123
colRe = regexp.MustCompile(`(?m)^\s*(\w+)\s+\w`)
133728e barerepo 1mo
126
// shape reduces DDL to table name -> sorted column names.
133728e barerepo 1mo
127
func shape(ddl string) map[string][]string {
133728e barerepo 1mo
128
out := map[string][]string{}
133728e barerepo 1mo
129
for _, m := range tableRe.FindAllStringSubmatch(ddl, -1) {
133728e barerepo 1mo
130
var cols []string
133728e barerepo 1mo
131
for _, line := range strings.Split(m[2], "\n") {
133728e barerepo 1mo
132
if strings.Contains(strings.ToUpper(line), "PRIMARY KEY (") {
133728e barerepo 1mo
133
continue
133728e barerepo 1mo
134
}
133728e barerepo 1mo
135
if c := colRe.FindStringSubmatch(line); c != nil {
133728e barerepo 1mo
136
cols = append(cols, c[1])
133728e barerepo 1mo
137
}
133728e barerepo 1mo
138
}
133728e barerepo 1mo
139
sort.Strings(cols)
133728e barerepo 1mo
140
out[m[1]] = cols
133728e barerepo 1mo
141
}
133728e barerepo 1mo
142
return out
133728e barerepo 1mo
145
func keysOf(m map[string][]string) []string {
133728e barerepo 1mo
146
var out []string
133728e barerepo 1mo
147
for k := range m {
133728e barerepo 1mo
148
out = append(out, k)
133728e barerepo 1mo
149
}
133728e barerepo 1mo
150
sort.Strings(out)
133728e barerepo 1mo
151
return out
133728e barerepo 1mo
154
func TestDatabaseKind(t *testing.T) {
133728e barerepo 1mo
155
cases := map[string]config.Kind{
133728e barerepo 1mo
156
"sqlite:///var/lib/barerepo/forge.db": config.SQLite,
133728e barerepo 1mo
157
"postgres://barerepo@localhost/barerepo": config.Postgres,
133728e barerepo 1mo
158
"postgresql://barerepo@localhost/barerepo": config.Postgres,
133728e barerepo 1mo
159
}
133728e barerepo 1mo
160
for url, want := range cases {
133728e barerepo 1mo
161
got, err := config.Config{Database: config.Database{URL: url}}.DatabaseKind()
133728e barerepo 1mo
162
if err != nil || got != want {
133728e barerepo 1mo
163
t.Errorf("%s: got %q %v, want %q", url, got, err, want)
133728e barerepo 1mo
164
}
133728e barerepo 1mo
165
}
133728e barerepo 1mo
166
for _, bad := range []string{"", "mysql://x", "/var/lib/barerepo/forge.db"} {
133728e barerepo 1mo
167
if _, err := (config.Config{Database: config.Database{URL: bad}}).DatabaseKind(); err == nil {
133728e barerepo 1mo
168
t.Errorf("%q was accepted", bad)
133728e barerepo 1mo
169
}
133728e barerepo 1mo
170
}
133728e barerepo 1mo
173
// The window has to fit leaving for a terminal and coming back, so this pins it.
133728e barerepo 1mo
174
func TestChallengeLifeFitsAPerson(t *testing.T) {
133728e barerepo 1mo
175
if ChallengeLife < 5*time.Minute {
133728e barerepo 1mo
176
t.Errorf("ChallengeLife is %s, which is not enough time to switch to a terminal and back", ChallengeLife)
133728e barerepo 1mo
177
}
133728e barerepo 1mo
178
if ChallengeLife > time.Hour {
133728e barerepo 1mo
179
t.Errorf("ChallengeLife is %s, which leaves a challenge open far longer than any sign-in takes", ChallengeLife)
133728e barerepo 1mo
180
}
133728e barerepo 1mo
183
// A nonce works once, and a wrong signature spends it, so nothing is tried repeatedly.
133728e barerepo 1mo
184
func TestChallengeIsSpentOnFirstUse(t *testing.T) {
133728e barerepo 1mo
185
ctx := context.Background()
133728e barerepo 1mo
186
db := open(t)
133728e barerepo 1mo
187
if _, err := db.CreateAccount(ctx, "john", keyA, false); err != nil {
133728e barerepo 1mo
188
t.Fatal(err)
133728e barerepo 1mo
189
}
133728e barerepo 1mo
190
if err := db.NewChallenge(ctx, "john", "the-nonce"); err != nil {
133728e barerepo 1mo
191
t.Fatal(err)
133728e barerepo 1mo
192
}
133728e barerepo 1mo
193
if c, err := db.TakeChallenge(ctx, "the-nonce"); err != nil || c.Account != "john" {
133728e barerepo 1mo
194
t.Fatalf("first use: %v %v", c, err)
133728e barerepo 1mo
195
}
133728e barerepo 1mo
196
if _, err := db.TakeChallenge(ctx, "the-nonce"); !errors.Is(err, ErrNotFound) {
133728e barerepo 1mo
197
t.Error("the same nonce was accepted twice")
133728e barerepo 1mo
198
}
133728e barerepo 1mo
201
func TestExpiredChallengeIsRefused(t *testing.T) {
133728e barerepo 1mo
202
ctx := context.Background()
133728e barerepo 1mo
203
db := open(t)
133728e barerepo 1mo
204
if _, err := db.CreateAccount(ctx, "john", keyA, false); err != nil {
133728e barerepo 1mo
205
t.Fatal(err)
133728e barerepo 1mo
206
}
133728e barerepo 1mo
207
if err := db.NewChallenge(ctx, "john", "stale"); err != nil {
133728e barerepo 1mo
208
t.Fatal(err)
133728e barerepo 1mo
209
}
133728e barerepo 1mo
210
// Hold the clock forward past the window rather than sleeping.
133728e barerepo 1mo
211
real := now
133728e barerepo 1mo
212
now = func() time.Time { return real().Add(ChallengeLife + time.Second) }
133728e barerepo 1mo
213
defer func() { now = real }()
133728e barerepo 1mo
215
if _, err := db.TakeChallenge(ctx, "stale"); !errors.Is(err, ErrNotFound) {
133728e barerepo 1mo
216
t.Error("an expired challenge was accepted")
133728e barerepo 1mo
217
}