diff --git a/README.md b/README.md
index 9e2e11b..90092d9 100644
--- a/README.md
+++ b/README.md
@@ -375,8 +375,8 @@ The CLI and API are considered mostly stable; they may grow a little but are des
and won't shrink. New behavior is expected to arrive through `client.Options` / `server.Options`
rather than changes to the teamclient/teamserver types.
-The **Possible enhancements** below are roughly one minor release each (`0.1.0`, `0.2.0`, …) toward
-`v1.0.0`.
+The **Possible enhancements** below were each roughly one minor release (`0.1.0`, `0.2.0`, …) toward
+`v1.0.0`, and have now all landed.
- Please open an issue or PR for any bug — it will be resolved promptly.
- Features and PRs are welcome when they're likely to help most users.
@@ -387,6 +387,6 @@ Not a roadmap — these are changes the author would gladly review contributions
library aims to stay small, with a precise role; contributions ideally strengthen the core/transport
code or widen interoperability with other Go programs.
-- [ ] Add support for encrypted sqlite by default.
-- [ ] Finish replacing logrus with the standard-library `slog`, behind a single package shared by client and server.
-- [ ] Add tests for the most sensitive paths (certificate management, database, etc.).
+- [x] Add support for encrypted sqlite. _(Opt-in via `server.WithDatabaseKey(key)`: the default, file-based SQLite database is then transparently encrypted at rest through the pure-Go [adiantum](https://github.com/ncruces/go-sqlite3/tree/main/vfs/adiantum) VFS — no CGO, works on the default and `wasm_sqlite` builds. The key is never persisted next to the database. Leaving it unset keeps the current plaintext behavior.)_
+- [x] Finish replacing logrus with the standard-library `slog`, behind a single package shared by client and server. _(Core is now `slog`-only, behind the public `team/log` package; logrus remains only in one example transport to demonstrate a self-owned backend.)_
+- [x] Add tests for the most sensitive paths (certificate management, database, etc.). _(The `log` package, version/transport flow, the certificate manager (PKI generation, storage round-trips, CA chain verification — now ~80% covered), the database DSN layer, and the teamserver user lifecycle (create/authenticate/delete revocation, mutual-TLS config) are now unit-tested.)_
diff --git a/go.mod b/go.mod
index 0950fdf..7018a13 100644
--- a/go.mod
+++ b/go.mod
@@ -10,9 +10,8 @@ require (
github.com/gofrs/uuid v4.4.0+incompatible
github.com/grpc-ecosystem/go-grpc-middleware v1.4.0
github.com/jedib0t/go-pretty/v6 v6.4.6
- github.com/lib/pq v1.10.9
- github.com/ncruces/go-sqlite3 v0.8.4
- github.com/ncruces/go-sqlite3/gormlite v0.8.4
+ github.com/ncruces/go-sqlite3 v0.22.0
+ github.com/ncruces/go-sqlite3/gormlite v0.22.0
github.com/rsteube/carapace v0.47.4
github.com/sirupsen/logrus v1.9.3
github.com/spf13/afero v1.14.0
@@ -23,16 +22,14 @@ require (
gorm.io/driver/mysql v1.5.7
gorm.io/driver/postgres v1.5.9
gorm.io/driver/sqlite v1.5.5
- gorm.io/gorm v1.25.10
- modernc.org/sqlite v1.23.1
+ gorm.io/gorm v1.25.12
)
require (
github.com/carapace-sh/carapace-shlex v1.0.1 // indirect
- github.com/dustin/go-humanize v1.0.1 // indirect
github.com/go-sql-driver/mysql v1.7.0 // indirect
github.com/golang/protobuf v1.5.3 // indirect
- github.com/google/uuid v1.3.0 // indirect
+ github.com/google/go-cmp v0.6.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
@@ -46,30 +43,19 @@ require (
github.com/mattn/go-runewidth v0.0.13 // indirect
github.com/mattn/go-sqlite3 v1.14.17 // indirect
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b // indirect
- github.com/ncruces/julianday v0.1.5 // indirect
- github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
+ github.com/ncruces/julianday v1.0.0 // indirect
github.com/rivo/uniseg v0.2.0 // indirect
github.com/rogpeppe/go-internal v1.11.0 // indirect
github.com/rsteube/carapace-shlex v0.1.1 // indirect
github.com/stretchr/testify v1.8.2 // indirect
- github.com/tetratelabs/wazero v1.4.0 // indirect
+ github.com/tetratelabs/wazero v1.8.2 // indirect
golang.org/x/crypto v0.37.0 // indirect
- golang.org/x/mod v0.17.0 // indirect
golang.org/x/net v0.39.0 // indirect
golang.org/x/sync v0.13.0 // indirect
golang.org/x/sys v0.32.0 // indirect
golang.org/x/term v0.31.0 // indirect
golang.org/x/text v0.24.0 // indirect
- golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect
google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
- lukechampine.com/uint128 v1.2.0 // indirect
- modernc.org/cc/v3 v3.40.0 // indirect
- modernc.org/ccgo/v3 v3.16.13 // indirect
- modernc.org/libc v1.22.5 // indirect
- modernc.org/mathutil v1.5.0 // indirect
- modernc.org/memory v1.5.0 // indirect
- modernc.org/opt v0.1.3 // indirect
- modernc.org/strutil v1.1.3 // indirect
- modernc.org/token v1.0.1 // indirect
+ lukechampine.com/adiantum v1.1.1 // indirect
)
diff --git a/go.sum b/go.sum
index d7abf50..619f95e 100644
--- a/go.sum
+++ b/go.sum
@@ -24,8 +24,6 @@ github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
-github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
-github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
@@ -51,10 +49,6 @@ github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5a
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
-github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
-github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
-github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
-github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 h1:UH//fgunKIs4JdUbpDl1VZCDaL56wXCB/5+wF6uHfaI=
github.com/grpc-ecosystem/go-grpc-middleware v1.4.0/go.mod h1:g5qyo/la0ALbONm6Vbp88Yd8NsDy6rZz+RcrMPxvld8=
github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec h1:qv2VnGeEQHchGaZ/u7lxST/RaJw+cv273q79D81Xbog=
@@ -86,8 +80,6 @@ github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NB
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
-github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
-github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
github.com/mattn/go-colorable v0.1.8 h1:c1ghPdyEDarC70ftn0y+A/Ee++9zz8ljHG1b13eJ0s8=
github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
@@ -101,21 +93,18 @@ github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6
github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1fWh90gTKwiN4QCGoY9TWyyO4=
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
-github.com/ncruces/go-sqlite3 v0.8.4 h1:nizhgJMMJJBrthESCwF30+oOvQkdtizgJ/v35Y0v+vg=
-github.com/ncruces/go-sqlite3 v0.8.4/go.mod h1:XvDtjKk5MgwHX7L4I7BPzzKl36bTZ7+Hr6Kr2QeVkVw=
-github.com/ncruces/go-sqlite3/gormlite v0.8.4 h1:omeGR0XofGGwlbWB5QSEdPQC0j58fDEULrVMLXTIt+M=
-github.com/ncruces/go-sqlite3/gormlite v0.8.4/go.mod h1:52uZNxrd8iQVjmxE6l3Dt71zoHpwnoDDFIqB1wW1+Cg=
-github.com/ncruces/julianday v0.1.5 h1:hDJ9ejiMp3DHsoZ5KW4c1lwfMjbARS7u/gbYcd0FBZk=
-github.com/ncruces/julianday v0.1.5/go.mod h1:Dusn2KvZrrovOMJuOt0TNXL6tB7U2E8kvza5fFc9G7g=
+github.com/ncruces/go-sqlite3 v0.22.0 h1:FkGSBhd0TY6e66k1LVhyEpA+RnG/8QkQNed5pjIk4cs=
+github.com/ncruces/go-sqlite3 v0.22.0/go.mod h1:ueXOZXYZS2OFQirCU3mHneDwJm5fGKHrtccYBeGEV7M=
+github.com/ncruces/go-sqlite3/gormlite v0.22.0 h1:1kn6lXjiBe+5up9KJ24dqGixbr+L6sQFrXc4ay9IMqg=
+github.com/ncruces/go-sqlite3/gormlite v0.22.0/go.mod h1:R0/mKQ7ahIY32z4ZlCVuwvSjlPbN9PN3b9NxyE3AzFQ=
+github.com/ncruces/julianday v1.0.0 h1:fH0OKwa7NWvniGQtxdJRxAgkBMolni2BjDHaWTxqt7M=
+github.com/ncruces/julianday v1.0.0/go.mod h1:Dusn2KvZrrovOMJuOt0TNXL6tB7U2E8kvza5fFc9G7g=
github.com/opentracing/opentracing-go v1.1.0/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/profile v1.6.0/go.mod h1:qBsxPvzyUincmltOk6iyRVxHYg4adc0OFOv72ZdLa18=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
-github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
-github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
-github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
@@ -150,8 +139,8 @@ github.com/stretchr/testify v1.7.4/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
-github.com/tetratelabs/wazero v1.4.0 h1:9/MirYvmkJ/zSUOygKY/ia3t+e+RqIZXKbylIby1WYk=
-github.com/tetratelabs/wazero v1.4.0/go.mod h1:0U0G41+ochRKoPKCJlh0jMg1CHkyfK8kDqiirMmKY8A=
+github.com/tetratelabs/wazero v1.8.2 h1:yIgLR/b2bN31bjxwXHD8a3d+BogigR952csSDdLYEv4=
+github.com/tetratelabs/wazero v1.8.2/go.mod h1:yAI0XTsMBhREkM/YDAK/zNou3GoiAce1P6+rp/wQhjs=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
@@ -173,8 +162,6 @@ golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHl
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
-golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA=
-golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -236,8 +223,6 @@ golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtn
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
-golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
-golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@@ -279,35 +264,9 @@ gorm.io/driver/postgres v1.5.9/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkw
gorm.io/driver/sqlite v1.5.5 h1:7MDMtUZhV065SilG62E0MquljeArQZNfJnjd9i9gx3E=
gorm.io/driver/sqlite v1.5.5/go.mod h1:6NgQ7sQWAIFsPrJJl1lSNSu2TABh0ZZ/zm5fosATavE=
gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
-gorm.io/gorm v1.25.10 h1:dQpO+33KalOA+aFYGlK+EfxcI5MbO7EP2yYygwh9h+s=
-gorm.io/gorm v1.25.10/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
+gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
+gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
-lukechampine.com/uint128 v1.2.0 h1:mBi/5l91vocEN8otkC5bDLhi2KdCticRiwbdB0O+rjI=
-lukechampine.com/uint128 v1.2.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
-modernc.org/cc/v3 v3.40.0 h1:P3g79IUS/93SYhtoeaHW+kRCIrYaxJ27MFPv+7kaTOw=
-modernc.org/cc/v3 v3.40.0/go.mod h1:/bTg4dnWkSXowUO6ssQKnOV0yMVxDYNIsIrzqTFDGH0=
-modernc.org/ccgo/v3 v3.16.13 h1:Mkgdzl46i5F/CNR/Kj80Ri59hC8TKAhZrYSaqvkwzUw=
-modernc.org/ccgo/v3 v3.16.13/go.mod h1:2Quk+5YgpImhPjv2Qsob1DnZ/4som1lJTodubIcoUkY=
-modernc.org/ccorpus v1.11.6 h1:J16RXiiqiCgua6+ZvQot4yUuUy8zxgqbqEEUuGPlISk=
-modernc.org/ccorpus v1.11.6/go.mod h1:2gEUTrWqdpH2pXsmTM1ZkjeSrUWDpjMu2T6m29L/ErQ=
-modernc.org/httpfs v1.0.6 h1:AAgIpFZRXuYnkjftxTAZwMIiwEqAfk8aVB2/oA6nAeM=
-modernc.org/httpfs v1.0.6/go.mod h1:7dosgurJGp0sPaRanU53W4xZYKh14wfzX420oZADeHM=
-modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
-modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
-modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
-modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
-modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
-modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
-modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
-modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
-modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM=
-modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=
-modernc.org/strutil v1.1.3 h1:fNMm+oJklMGYfU9Ylcywl0CO5O6nTfaowNsh2wpPjzY=
-modernc.org/strutil v1.1.3/go.mod h1:MEHNA7PdEnEwLvspRMtWTNnp2nnyvMfkimT1NKNAGbw=
-modernc.org/tcl v1.15.2 h1:C4ybAYCGJw968e+Me18oW55kD/FexcHbqH2xak1ROSY=
-modernc.org/tcl v1.15.2/go.mod h1:3+k/ZaEbKrC8ePv8zJWPtBSW0V7Gg9g8rkmhI1Kfs3c=
-modernc.org/token v1.0.1 h1:A3qvTqOwexpfZZeyI0FeGPDlSWX5pjZu9hF4lU+EKWg=
-modernc.org/token v1.0.1/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
-modernc.org/z v1.7.3 h1:zDJf6iHjrnB+WRD88stbXokugjyc0/pB91ri1gO6LZY=
-modernc.org/z v1.7.3/go.mod h1:Ipv4tsdxZRbQyLq9Q1M6gdbkxYzdlrciF2Hi/lS7nWE=
+lukechampine.com/adiantum v1.1.1 h1:4fp6gTxWCqpEbLy40ExiYDDED3oUNWx5cTqBCtPdZqA=
+lukechampine.com/adiantum v1.1.1/go.mod h1:LrAYVnTYLnUtE/yMp5bQr0HstAf060YUF8nM0B6+rUw=
diff --git a/internal/certs/certs_test.go b/internal/certs/certs_test.go
new file mode 100644
index 0000000..8e23219
--- /dev/null
+++ b/internal/certs/certs_test.go
@@ -0,0 +1,340 @@
+package certs
+
+/*
+ team - Embedded teamserver for Go programs and CLI applications
+ Copyright (C) 2023 Reeflective
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see .
+*/
+
+import (
+ "bytes"
+ "crypto/x509"
+ "encoding/pem"
+ "io"
+ "log/slog"
+ "testing"
+
+ "gorm.io/gorm"
+
+ "github.com/reeflective/team/internal/assets"
+ "github.com/reeflective/team/internal/db"
+)
+
+// newTestManager builds a certificate manager backed by an in-memory filesystem
+// and an in-memory SQLite database. Constructing the manager also generates the
+// user certificate authority, so the returned manager is ready to sign certs.
+func newTestManager(t *testing.T) *Manager {
+ t.Helper()
+
+ logger := slog.New(slog.NewTextHandler(io.Discard, nil))
+
+ dbConfig := &db.Config{
+ Dialect: db.Sqlite,
+ Database: db.SQLiteInMemoryHost,
+ MaxIdleConns: 1,
+ MaxOpenConns: 1,
+ LogLevel: "error",
+ }
+
+ database, err := db.NewClient(dbConfig, logger)
+ if err != nil {
+ t.Fatalf("failed to create in-memory database: %v", err)
+ }
+
+ fs := assets.NewFileSystem(true)
+
+ return NewManager(fs, database, logger, "testapp", "/app")
+}
+
+// TestNewManagerInitializesCA verifies that constructing a manager creates a
+// usable user certificate authority: it is retrievable both parsed and as PEM,
+// and the CA key files were written to the (in-memory) filesystem.
+func TestNewManagerInitializesCA(t *testing.T) {
+ certs := newTestManager(t)
+
+ caCert, caKey, err := certs.GetUsersCA()
+ if err != nil {
+ t.Fatalf("GetUsersCA: %v", err)
+ }
+ if caCert == nil || caKey == nil {
+ t.Fatal("GetUsersCA returned nil certificate or key")
+ }
+ if !caCert.IsCA {
+ t.Fatal("user CA certificate is not marked as a CA")
+ }
+
+ certPEM, keyPEM, err := certs.GetUsersCAPEM()
+ if err != nil {
+ t.Fatalf("GetUsersCAPEM: %v", err)
+ }
+ if len(certPEM) == 0 || len(keyPEM) == 0 {
+ t.Fatal("GetUsersCAPEM returned empty certificate or key")
+ }
+ if block, _ := pem.Decode(certPEM); block == nil || block.Type != "CERTIFICATE" {
+ t.Fatal("CA certificate PEM is not a valid CERTIFICATE block")
+ }
+}
+
+// TestECCCertificateRoundTrip exercises the full lifecycle of an ECC leaf
+// certificate: generate + save, fetch it back byte-for-byte, then remove it and
+// confirm it is gone.
+func TestECCCertificateRoundTrip(t *testing.T) {
+ certs := newTestManager(t)
+
+ cn := "roundtrip.example.com"
+ cert, key := certs.GenerateECCCertificate(userCA, cn, false, false)
+ if len(cert) == 0 || len(key) == 0 {
+ t.Fatal("GenerateECCCertificate returned empty material")
+ }
+
+ if err := certs.saveCertificate(userCA, ECCKey, cn, cert, key); err != nil {
+ t.Fatalf("saveCertificate: %v", err)
+ }
+
+ gotCert, gotKey, err := certs.GetECCCertificate(userCA, cn)
+ if err != nil {
+ t.Fatalf("GetECCCertificate: %v", err)
+ }
+ if !bytes.Equal(cert, gotCert) || !bytes.Equal(key, gotKey) {
+ t.Fatal("fetched certificate/key does not match the stored material")
+ }
+
+ if err := certs.RemoveCertificate(userCA, ECCKey, cn); err != nil {
+ t.Fatalf("RemoveCertificate: %v", err)
+ }
+
+ if _, _, err := certs.GetECCCertificate(userCA, cn); err != ErrCertDoesNotExist {
+ t.Fatalf("expected ErrCertDoesNotExist after removal, got %v", err)
+ }
+}
+
+// TestRSACertificateRoundTrip does the same lifecycle check for RSA material,
+// covering the RSA key generation and PEM-encoding branches.
+func TestRSACertificateRoundTrip(t *testing.T) {
+ certs := newTestManager(t)
+
+ cn := "rsa.example.com"
+ cert, key := certs.GenerateRSACertificate(userCA, cn, false, false)
+ if len(cert) == 0 || len(key) == 0 {
+ t.Fatal("GenerateRSACertificate returned empty material")
+ }
+
+ if block, _ := pem.Decode(key); block == nil || block.Type != "RSA PRIVATE KEY" {
+ t.Fatal("RSA private key PEM block is malformed")
+ }
+
+ if err := certs.saveCertificate(userCA, RSAKey, cn, cert, key); err != nil {
+ t.Fatalf("saveCertificate: %v", err)
+ }
+
+ gotCert, gotKey, err := certs.GetRSACertificate(userCA, cn)
+ if err != nil {
+ t.Fatalf("GetRSACertificate: %v", err)
+ }
+ if !bytes.Equal(cert, gotCert) || !bytes.Equal(key, gotKey) {
+ t.Fatal("fetched RSA certificate/key does not match stored material")
+ }
+}
+
+// TestGetCertificateNotFound confirms that fetching an unknown certificate
+// returns the sentinel error and no material.
+func TestGetCertificateNotFound(t *testing.T) {
+ certs := newTestManager(t)
+
+ cert, key, err := certs.GetECCCertificate(userCA, "nobody.example.com")
+ if err != ErrCertDoesNotExist {
+ t.Fatalf("expected ErrCertDoesNotExist, got %v", err)
+ }
+ if cert != nil || key != nil {
+ t.Fatal("expected nil material for a missing certificate")
+ }
+}
+
+// TestInvalidKeyTypeRejected ensures the key-type guard rejects unknown key
+// namespaces on every entry point that takes one.
+func TestInvalidKeyTypeRejected(t *testing.T) {
+ certs := newTestManager(t)
+
+ if _, _, err := certs.GetCertificate(userCA, "dsa", "x"); err == nil {
+ t.Fatal("GetCertificate accepted an invalid key type")
+ }
+ if err := certs.RemoveCertificate(userCA, "dsa", "x"); err == nil {
+ t.Fatal("RemoveCertificate accepted an invalid key type")
+ }
+ if err := certs.saveCertificate(userCA, "dsa", "x", nil, nil); err == nil {
+ t.Fatal("saveCertificate accepted an invalid key type")
+ }
+}
+
+// TestUserClientCertificateLifecycle covers the user-facing helpers used when a
+// teamserver mints, lists and revokes a client's credentials.
+func TestUserClientCertificateLifecycle(t *testing.T) {
+ certs := newTestManager(t)
+
+ if _, _, err := certs.UserClientGenerateCertificate("alice"); err != nil {
+ t.Fatalf("UserClientGenerateCertificate: %v", err)
+ }
+
+ cert, key, err := certs.UserClientGetCertificate("alice")
+ if err != nil {
+ t.Fatalf("UserClientGetCertificate: %v", err)
+ }
+ if len(cert) == 0 || len(key) == 0 {
+ t.Fatal("client certificate material is empty")
+ }
+
+ listed := certs.UserClientListCertificates()
+ if len(listed) != 1 {
+ t.Fatalf("expected exactly 1 listed client certificate, got %d", len(listed))
+ }
+ if listed[0].Subject.CommonName != "alice" {
+ t.Fatalf("listed certificate CN = %q, want alice", listed[0].Subject.CommonName)
+ }
+
+ if err := certs.UserClientRemoveCertificate("alice"); err != nil {
+ t.Fatalf("UserClientRemoveCertificate: %v", err)
+ }
+ if _, _, err := certs.UserClientGetCertificate("alice"); err != ErrCertDoesNotExist {
+ t.Fatalf("expected ErrCertDoesNotExist after revocation, got %v", err)
+ }
+ if got := certs.UserClientListCertificates(); len(got) != 0 {
+ t.Fatalf("expected no client certificates after revocation, got %d", len(got))
+ }
+}
+
+// TestUserServerCertificate covers the lazy generate-then-fetch pattern used by
+// UsersTLSConfig for the server-side leaf certificate.
+func TestUserServerCertificate(t *testing.T) {
+ certs := newTestManager(t)
+
+ // Not generated yet.
+ if _, _, err := certs.UserServerGetCertificate(); err != ErrCertDoesNotExist {
+ t.Fatalf("expected ErrCertDoesNotExist before generation, got %v", err)
+ }
+
+ if _, _, err := certs.UserServerGenerateCertificate(); err != nil {
+ t.Fatalf("UserServerGenerateCertificate: %v", err)
+ }
+
+ cert, key, err := certs.UserServerGetCertificate()
+ if err != nil {
+ t.Fatalf("UserServerGetCertificate: %v", err)
+ }
+ if len(cert) == 0 || len(key) == 0 {
+ t.Fatal("server certificate material is empty")
+ }
+}
+
+// TestClientCertificateChainsToCA is the core PKI assertion: a generated client
+// certificate must actually verify against the user CA. This proves the signing
+// chain (not just that bytes round-trip through the database).
+func TestClientCertificateChainsToCA(t *testing.T) {
+ certs := newTestManager(t)
+
+ caCert, _, err := certs.GetUsersCA()
+ if err != nil {
+ t.Fatalf("GetUsersCA: %v", err)
+ }
+
+ leafPEM, _, err := certs.UserClientGenerateCertificate("bob")
+ if err != nil {
+ t.Fatalf("UserClientGenerateCertificate: %v", err)
+ }
+
+ block, _ := pem.Decode(leafPEM)
+ if block == nil {
+ t.Fatal("failed to decode leaf certificate PEM")
+ }
+ leaf, err := x509.ParseCertificate(block.Bytes)
+ if err != nil {
+ t.Fatalf("ParseCertificate(leaf): %v", err)
+ }
+
+ roots := x509.NewCertPool()
+ roots.AddCert(caCert)
+
+ if _, err := leaf.Verify(x509.VerifyOptions{
+ Roots: roots,
+ KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth},
+ }); err != nil {
+ t.Fatalf("client certificate does not chain to the user CA: %v", err)
+ }
+}
+
+// TestRootOnlyVerifyCertificate revives the (previously commented-out) contract
+// for the hostname-skipping verifier: a certificate signed by the CA passes,
+// one signed by a different CA is rejected.
+func TestRootOnlyVerifyCertificate(t *testing.T) {
+ certs := newTestManager(t)
+
+ caPEM, _, err := certs.GetUsersCAPEM()
+ if err != nil {
+ t.Fatalf("GetUsersCAPEM: %v", err)
+ }
+
+ // RootOnlyVerifyCertificate skips only the hostname check; it still enforces
+ // Go's default EKU (server-auth), so we verify with server certificates.
+ //
+ // A leaf signed by our CA must verify.
+ leafPEM, _ := certs.GenerateECCCertificate(userCA, "localhost", false, false)
+ leafBlock, _ := pem.Decode(leafPEM)
+ if leafBlock == nil {
+ t.Fatal("failed to decode leaf certificate PEM")
+ }
+ if err := RootOnlyVerifyCertificate(string(caPEM), [][]byte{leafBlock.Bytes}); err != nil {
+ t.Fatalf("RootOnlyVerifyCertificate rejected a validly-signed cert: %v", err)
+ }
+
+ // A leaf signed by a DIFFERENT CA must be rejected.
+ other := newTestManagerWithApp(t, "otherapp", "/other")
+ foreignPEM, _ := other.GenerateECCCertificate(userCA, "localhost", false, false)
+ foreignBlock, _ := pem.Decode(foreignPEM)
+ if foreignBlock == nil {
+ t.Fatal("failed to decode foreign certificate PEM")
+ }
+ if err := RootOnlyVerifyCertificate(string(caPEM), [][]byte{foreignBlock.Bytes}); err == nil {
+ t.Fatal("RootOnlyVerifyCertificate accepted a certificate signed by a foreign CA")
+ }
+
+ // A malformed CA PEM must be rejected outright (regression guard: the error
+ // used to be constructed but never returned).
+ if err := RootOnlyVerifyCertificate("not a pem", [][]byte{leafBlock.Bytes}); err == nil {
+ t.Fatal("RootOnlyVerifyCertificate accepted a malformed root certificate")
+ }
+}
+
+// newTestManagerWithApp is like newTestManager but lets a test create a second,
+// independent CA (distinct app name + filesystem root + database).
+func newTestManagerWithApp(t *testing.T, appName, appDir string) *Manager {
+ t.Helper()
+
+ logger := slog.New(slog.NewTextHandler(io.Discard, nil))
+
+ dbConfig := &db.Config{
+ Dialect: db.Sqlite,
+ Database: db.SQLiteInMemoryHost,
+ MaxIdleConns: 1,
+ MaxOpenConns: 1,
+ LogLevel: "error",
+ }
+
+ var database *gorm.DB
+ database, err := db.NewClient(dbConfig, logger)
+ if err != nil {
+ t.Fatalf("failed to create in-memory database: %v", err)
+ }
+
+ return NewManager(assets.NewFileSystem(true), database, logger, appName, appDir)
+}
diff --git a/internal/certs/tls.go b/internal/certs/tls.go
index 8bcbcd5..b88c2bd 100644
--- a/internal/certs/tls.go
+++ b/internal/certs/tls.go
@@ -60,7 +60,7 @@ func RootOnlyVerifyCertificate(caCertificate string, rawCerts [][]byte) error {
ok := roots.AppendCertsFromPEM([]byte(caCertificate))
if !ok {
- fmt.Errorf("Failed to parse root certificate")
+ return fmt.Errorf("Failed to parse root certificate")
}
cert, err := x509.ParseCertificate(rawCerts[0]) // We should only get one cert
diff --git a/internal/db/config.go b/internal/db/config.go
index 6b95e61..598a88c 100644
--- a/internal/db/config.go
+++ b/internal/db/config.go
@@ -47,6 +47,13 @@ type Config struct {
MaxOpenConns int `json:"max_open_conns"`
LogLevel string `json:"log_level"`
+
+ // EncryptionKey, when set, enables transparent encryption-at-rest for
+ // on-disk SQLite databases through the pure-Go adiantum VFS (available on
+ // the default and wasm_sqlite builds). It is deliberately NOT serialized:
+ // persisting the key next to the database it protects would defeat the
+ // purpose. Applications supply it out-of-band (option, env, KMS, prompt).
+ EncryptionKey string `json:"-"`
}
// DSN - Get the db connections string
@@ -55,9 +62,22 @@ func (c *Config) DSN() (string, error) {
switch c.Dialect {
case Sqlite:
filePath := c.Database
- params := encodeParams(c.Params)
- return fmt.Sprintf("file:%s?%s", filePath, params), nil
+ params := url.Values{}
+ for key, value := range c.Params {
+ params.Add(key, value)
+ }
+
+ // Enable transparent encryption-at-rest when a key is provided and the
+ // database actually lives on disk. The adiantum VFS is pure-Go, so this
+ // works on the default and wasm_sqlite builds; in-memory databases have
+ // nothing on disk to encrypt, and the cgo_sqlite build has no such VFS.
+ if c.EncryptionKey != "" && filePath != SQLiteInMemoryHost {
+ params.Set("vfs", "adiantum")
+ params.Set("textkey", c.EncryptionKey)
+ }
+
+ return fmt.Sprintf("file:%s?%s", filePath, params.Encode()), nil
case MySQL:
user := url.QueryEscape(c.Username)
diff --git a/internal/db/config_test.go b/internal/db/config_test.go
new file mode 100644
index 0000000..d04336b
--- /dev/null
+++ b/internal/db/config_test.go
@@ -0,0 +1,200 @@
+package db
+
+/*
+ team - Embedded teamserver for Go programs and CLI applications
+ Copyright (C) 2023 Reeflective
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see .
+*/
+
+import (
+ "errors"
+ "net/url"
+ "strings"
+ "testing"
+)
+
+// TestDSNSqlite pins the SQLite DSN format: a file: URI with the database path
+// and encoded params. This is the default/in-memory backend and the one path
+// the integration tests already exercise, kept here as an explicit contract.
+func TestDSNSqlite(t *testing.T) {
+ cfg := &Config{
+ Dialect: Sqlite,
+ Database: SQLiteInMemoryHost,
+ Params: map[string]string{"cache": "shared"},
+ }
+
+ dsn, err := cfg.DSN()
+ if err != nil {
+ t.Fatalf("DSN(sqlite): unexpected error %v", err)
+ }
+
+ if !strings.HasPrefix(dsn, "file::memory:?") {
+ t.Fatalf("sqlite DSN must be a file: URI for the in-memory host, got %q", dsn)
+ }
+ if !strings.Contains(dsn, "cache=shared") {
+ t.Fatalf("sqlite DSN must carry encoded params, got %q", dsn)
+ }
+}
+
+// TestDSNSqliteEncrypted verifies that supplying an encryption key routes an
+// on-disk SQLite database through the adiantum VFS, with the key carried as a
+// (URL-encoded) textkey parameter.
+func TestDSNSqliteEncrypted(t *testing.T) {
+ cfg := &Config{
+ Dialect: Sqlite,
+ Database: "/var/lib/team/app.db",
+ EncryptionKey: "s3cr3t key/with=chars",
+ }
+
+ dsn, err := cfg.DSN()
+ if err != nil {
+ t.Fatalf("DSN(encrypted sqlite): %v", err)
+ }
+
+ if !strings.Contains(dsn, "vfs=adiantum") {
+ t.Fatalf("encrypted DSN must select the adiantum VFS, got %q", dsn)
+ }
+ if !strings.Contains(dsn, "textkey="+url.QueryEscape("s3cr3t key/with=chars")) {
+ t.Fatalf("encrypted DSN must carry the URL-encoded textkey, got %q", dsn)
+ }
+ // The raw key with its unescaped special characters must not appear.
+ if strings.Contains(dsn, "s3cr3t key/with=chars") {
+ t.Fatalf("encrypted DSN leaked an unescaped key, got %q", dsn)
+ }
+}
+
+// TestDSNSqliteInMemoryNotEncrypted ensures the encryption key is ignored for
+// in-memory databases: there is nothing on disk to protect, and selecting the
+// adiantum VFS there would only add overhead.
+func TestDSNSqliteInMemoryNotEncrypted(t *testing.T) {
+ cfg := &Config{
+ Dialect: Sqlite,
+ Database: SQLiteInMemoryHost,
+ EncryptionKey: "ignored-for-memory",
+ }
+
+ dsn, err := cfg.DSN()
+ if err != nil {
+ t.Fatalf("DSN(in-memory): %v", err)
+ }
+ if strings.Contains(dsn, "adiantum") || strings.Contains(dsn, "textkey") {
+ t.Fatalf("in-memory DSN must not be encrypted, got %q", dsn)
+ }
+}
+
+// TestDSNSqlitePlaintextByDefault pins the opt-in contract: with no key, the
+// DSN is the plain file: URI with no VFS selected.
+func TestDSNSqlitePlaintextByDefault(t *testing.T) {
+ cfg := &Config{Dialect: Sqlite, Database: "/var/lib/team/app.db"}
+
+ dsn, err := cfg.DSN()
+ if err != nil {
+ t.Fatalf("DSN(plaintext): %v", err)
+ }
+ if strings.Contains(dsn, "adiantum") || strings.Contains(dsn, "textkey") {
+ t.Fatalf("default DSN must be plaintext (opt-in encryption), got %q", dsn)
+ }
+}
+
+// TestDSNMySQL checks the go-sql-driver/mysql DSN layout and, importantly, that
+// credentials and database names are URL-query-escaped so that special
+// characters in a password cannot corrupt the DSN.
+func TestDSNMySQL(t *testing.T) {
+ cfg := &Config{
+ Dialect: MySQL,
+ Username: "team user",
+ Password: "p@ss:w/rd",
+ Database: "team db",
+ Host: "db.example.com",
+ Port: 3306,
+ }
+
+ dsn, err := cfg.DSN()
+ if err != nil {
+ t.Fatalf("DSN(mysql): unexpected error %v", err)
+ }
+
+ // user:password@tcp(host:port)/db?params
+ if !strings.Contains(dsn, "@tcp(db.example.com:3306)/") {
+ t.Fatalf("mysql DSN missing tcp host section, got %q", dsn)
+ }
+ if !strings.Contains(dsn, url.QueryEscape("p@ss:w/rd")) {
+ t.Fatalf("mysql DSN must URL-escape the password, got %q", dsn)
+ }
+ // The raw, unescaped password must not leak into the DSN.
+ if strings.Contains(dsn, "p@ss:w/rd") {
+ t.Fatalf("mysql DSN leaked the raw unescaped password, got %q", dsn)
+ }
+}
+
+// TestDSNPostgres checks the key=value Postgres DSN layout and that all
+// user-controlled fields are URL-escaped.
+func TestDSNPostgres(t *testing.T) {
+ cfg := &Config{
+ Dialect: Postgres,
+ Username: "team user",
+ Password: "p@ss word",
+ Database: "team db",
+ Host: "db.example.com",
+ Port: 5432,
+ }
+
+ dsn, err := cfg.DSN()
+ if err != nil {
+ t.Fatalf("DSN(postgres): unexpected error %v", err)
+ }
+
+ for _, want := range []string{
+ "host=db.example.com",
+ "port=5432",
+ "user=" + url.QueryEscape("team user"),
+ "password=" + url.QueryEscape("p@ss word"),
+ "dbname=" + url.QueryEscape("team db"),
+ } {
+ if !strings.Contains(dsn, want) {
+ t.Fatalf("postgres DSN missing %q, got %q", want, dsn)
+ }
+ }
+}
+
+// TestDSNUnsupportedDialect ensures an unknown dialect is rejected with the
+// sentinel error rather than producing a bogus connection string.
+func TestDSNUnsupportedDialect(t *testing.T) {
+ cfg := &Config{Dialect: "oracle"}
+
+ dsn, err := cfg.DSN()
+ if err == nil {
+ t.Fatalf("DSN(unsupported): expected an error, got dsn %q", dsn)
+ }
+ if !errors.Is(err, ErrUnsupportedDialect) {
+ t.Fatalf("DSN(unsupported): expected ErrUnsupportedDialect, got %v", err)
+ }
+ if dsn != "" {
+ t.Fatalf("DSN(unsupported): expected empty dsn on error, got %q", dsn)
+ }
+}
+
+// TestEncodeParams verifies params are deterministically URL-encoded (sorted by
+// key) so DSNs are stable, and that an empty map yields an empty string.
+func TestEncodeParams(t *testing.T) {
+ if got := encodeParams(nil); got != "" {
+ t.Fatalf("encodeParams(nil): expected empty, got %q", got)
+ }
+
+ got := encodeParams(map[string]string{"b": "2", "a": "1"})
+ if got != "a=1&b=2" {
+ t.Fatalf("encodeParams: expected deterministic sorted output a=1&b=2, got %q", got)
+ }
+}
diff --git a/internal/db/encryption_test.go b/internal/db/encryption_test.go
new file mode 100644
index 0000000..f5742e5
--- /dev/null
+++ b/internal/db/encryption_test.go
@@ -0,0 +1,144 @@
+//go:build !cgo_sqlite
+
+package db
+
+/*
+ team - Embedded teamserver for Go programs and CLI applications
+ Copyright (C) 2023 Reeflective
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see .
+*/
+
+import (
+ "bytes"
+ "io"
+ "log/slog"
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+// The adiantum encryption VFS is only available on the pure-Go builds (default
+// and wasm_sqlite); the cgo_sqlite build uses a different SQLite engine, so this
+// file is excluded there via the build constraint above.
+
+const plaintextMarker = "ENCRYPTION_AT_REST_PLAINTEXT_MARKER"
+
+func newTestDBConfig(path, key string) *Config {
+ return &Config{
+ Dialect: Sqlite,
+ Database: path,
+ MaxIdleConns: 1,
+ MaxOpenConns: 1,
+ LogLevel: "error",
+ EncryptionKey: key,
+ }
+}
+
+func discardLogger() *slog.Logger {
+ return slog.New(slog.NewTextHandler(io.Discard, nil))
+}
+
+// closeDB releases the underlying connection so the file is flushed and can be
+// reopened / inspected.
+func closeDB(t *testing.T, cfg *Config) {
+ t.Helper()
+
+ client, err := NewClient(cfg, discardLogger())
+ if err != nil {
+ t.Fatalf("NewClient: %v", err)
+ }
+
+ if err := client.Create(&Certificate{CommonName: plaintextMarker}).Error; err != nil {
+ t.Fatalf("insert marker: %v", err)
+ }
+
+ sqlDB, err := client.DB()
+ if err != nil {
+ t.Fatalf("client.DB: %v", err)
+ }
+ if err := sqlDB.Close(); err != nil {
+ t.Fatalf("close: %v", err)
+ }
+}
+
+// TestEncryptedDatabaseAtRest is the end-to-end proof that WithDatabaseKey
+// actually encrypts the database on disk: the file contains neither the
+// plaintext marker nor the recognizable SQLite header, a wrong key cannot read
+// it, and the correct key round-trips the data.
+func TestEncryptedDatabaseAtRest(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "encrypted.db")
+ const key = "correct horse battery staple"
+
+ // 1. Write with a key, then close.
+ closeDB(t, newTestDBConfig(path, key))
+
+ // 2. The on-disk file must be encrypted.
+ raw, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatalf("read db file: %v", err)
+ }
+ if len(raw) == 0 {
+ t.Fatal("database file is empty")
+ }
+ if bytes.Contains(raw, []byte(plaintextMarker)) {
+ t.Fatal("plaintext marker found in the on-disk database: not encrypted")
+ }
+ if bytes.HasPrefix(raw, []byte("SQLite format 3")) {
+ t.Fatal("unencrypted SQLite header present in the on-disk database")
+ }
+
+ // 3. A wrong key must not be able to read the data.
+ wrong, err := NewClient(newTestDBConfig(path, "wrong key"), discardLogger())
+ if err == nil {
+ var got Certificate
+ if err := wrong.Where(&Certificate{CommonName: plaintextMarker}).First(&got).Error; err == nil {
+ t.Fatal("a wrong key was able to decrypt and read the database")
+ }
+ if sqlDB, derr := wrong.DB(); derr == nil {
+ sqlDB.Close()
+ }
+ }
+
+ // 4. The correct key round-trips the data.
+ right, err := NewClient(newTestDBConfig(path, key), discardLogger())
+ if err != nil {
+ t.Fatalf("NewClient(correct key): %v", err)
+ }
+ var got Certificate
+ if err := right.Where(&Certificate{CommonName: plaintextMarker}).First(&got).Error; err != nil {
+ t.Fatalf("correct key failed to read the marker back: %v", err)
+ }
+ if got.CommonName != plaintextMarker {
+ t.Fatalf("round-trip mismatch: got %q", got.CommonName)
+ }
+}
+
+// TestUnencryptedDatabaseIsPlaintext is the control: with no key, the same file
+// is a normal SQLite database (opt-in encryption leaves the default untouched).
+func TestUnencryptedDatabaseIsPlaintext(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "plain.db")
+
+ closeDB(t, newTestDBConfig(path, ""))
+
+ raw, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatalf("read db file: %v", err)
+ }
+ if !bytes.HasPrefix(raw, []byte("SQLite format 3")) {
+ t.Fatal("expected a standard SQLite header for an unencrypted database")
+ }
+}
diff --git a/internal/db/sql-go.go b/internal/db/sql-go.go
index 302aa0f..20553b5 100644
--- a/internal/db/sql-go.go
+++ b/internal/db/sql-go.go
@@ -24,6 +24,9 @@ import (
// Embed the sqlite code into our teamserver.
_ "github.com/ncruces/go-sqlite3/embed"
"github.com/ncruces/go-sqlite3/gormlite"
+ // Register the pure-Go adiantum VFS so an encryption key in the DSN
+ // (vfs=adiantum&textkey=...) transparently encrypts the database at rest.
+ _ "github.com/ncruces/go-sqlite3/vfs/adiantum"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
diff --git a/internal/db/sql-wasm.go b/internal/db/sql-wasm.go
index 213b48d..3997eba 100644
--- a/internal/db/sql-wasm.go
+++ b/internal/db/sql-wasm.go
@@ -21,20 +21,18 @@ package db
*/
import (
- // Core code.
- _ "github.com/ncruces/go-sqlite3"
- // Driver code.
- _ "github.com/ncruces/go-sqlite3/driver"
// Embedded SQLite instance.
_ "github.com/ncruces/go-sqlite3/embed"
+ "github.com/ncruces/go-sqlite3/gormlite"
+ // Register the pure-Go adiantum VFS so an encryption key in the DSN
+ // (vfs=adiantum&textkey=...) transparently encrypts the database at rest.
+ _ "github.com/ncruces/go-sqlite3/vfs/adiantum"
"gorm.io/gorm"
"gorm.io/gorm/logger"
-
- "github.com/reeflective/team/internal/db/wasmsqlite"
)
func sqliteClient(dsn string, log logger.Interface) (*gorm.DB, error) {
- return gorm.Open(wasmsqlite.Open(dsn), &gorm.Config{
+ return gorm.Open(gormlite.Open(dsn), &gorm.Config{
PrepareStmt: true,
Logger: log,
})
diff --git a/internal/db/sql.go b/internal/db/sql.go
index c711784..168d065 100644
--- a/internal/db/sql.go
+++ b/internal/db/sql.go
@@ -85,6 +85,16 @@ func NewClient(dbConfig *Config, dbLogger *slog.Logger) (*gorm.DB, error) {
return nil, fmt.Errorf("%w: '%s'", ErrUnsupportedDialect, dbConfig.Dialect)
}
+ // For SQLite, force an actual page read now so that a wrong encryption key
+ // (or an otherwise corrupt/unreadable file) surfaces here as a clean error,
+ // instead of panicking later inside AutoMigrate's schema introspection.
+ if dbConfig.Dialect == Sqlite {
+ var count int
+ if err := dbClient.Raw("SELECT count(*) FROM sqlite_master").Scan(&count).Error; err != nil {
+ return nil, fmt.Errorf("Database open failed (wrong encryption key or corrupt database?): %w", err)
+ }
+ }
+
err = dbClient.AutoMigrate(Schema()...)
if err != nil {
dbLogger.Error(err.Error())
diff --git a/internal/db/wasmsqlite/License b/internal/db/wasmsqlite/License
deleted file mode 100644
index 037e165..0000000
--- a/internal/db/wasmsqlite/License
+++ /dev/null
@@ -1,21 +0,0 @@
-The MIT License (MIT)
-
-Copyright (c) 2013-NOW Jinzhu
-
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in
-all copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-THE SOFTWARE.
diff --git a/internal/db/wasmsqlite/README.md b/internal/db/wasmsqlite/README.md
deleted file mode 100644
index 7ffa285..0000000
--- a/internal/db/wasmsqlite/README.md
+++ /dev/null
@@ -1,56 +0,0 @@
-
-
-
[](https://hits.seeyoufarm.com)
-# Pure-Go SQLite driver for GORM
-Pure-go (without cgo) implementation of SQLite driver for [GORM](https://gorm.io/)
-This driver has SQLite embedded, you don't need to install one separately.
-
-# Usage
-
-```go
-import (
- "github.com/glebarez/sqlite"
- "gorm.io/gorm"
-)
-
-db, err := gorm.Open(sqlite.Open("sqlite.db"), &gorm.Config{})
-```
-
-### In-memory DB example
-```go
-db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
-```
-
-### Foreign-key constraint activation
-Foreign-key constraint is disabled by default in SQLite. To activate it, use connection URL parameter:
-```go
-db, err := gorm.Open(sqlite.Open(":memory:?_pragma=foreign_keys(1)"), &gorm.Config{})
-```
-More info: [https://www.sqlite.org/foreignkeys.html](https://www.sqlite.org/foreignkeys.html)
-
-# FAQ
-## How is this better than standard GORM SQLite driver?
-The [standard GORM driver for SQLite](https://github.com/go-gorm/sqlite) has one major drawback: it is based on a [Go-bindings of SQLite C-source](https://github.com/mattn/go-sqlite3) (this is called [cgo](https://go.dev/blog/cgo)). This fact imposes following restrictions on Go developers:
-- to build and run your code, you will need a C compiler installed on a machine
-- SQLite has many features that need to be enabled at compile time (e.g. [json support](https://www.sqlite.org/json1.html)). If you plan to use those, you will have to include proper build tags for every ```go``` command to work properly (```go run```, ```go test```, etc.).
-- Because of C-compiler requirement, you can't build your Go code inside tiny stripped containers like (golang-alpine)
-- Building on GCP is not possible because Google Cloud Platform does not allow gcc to be executed.
-
-**Instead**, this driver is based on pure-Go implementation of SQLite (https://gitlab.com/cznic/sqlite), which is basically an original SQLite C-source AST, translated into Go! So, you may be sure you're using the original SQLite implementation under the hood.
-
-## Is this tested good ?
-Yes, The CI pipeline of this driver employs [whole test base](https://github.com/go-gorm/gorm/tree/master/tests) of GORM, which includes more than **12k** tests (see badge on the page-top). Testing is run against latest major releases of Go:
-- 1.18
-- 1.19
-
-In following environments:
-- Linux
-- Windows
-- MacOS
-
-## Is it fast?
-Well, it's slower than CGo implementation, but not terribly. See the [bechmark of underlying pure-Go driver vs CGo implementation](https://github.com/glebarez/go-sqlite/tree/master/benchmark).
-
-## Included features
-- JSON1 (https://www.sqlite.org/json1.html)
-- Math functions (https://www.sqlite.org/lang_mathfunc.html)
diff --git a/internal/db/wasmsqlite/ddlmod.go b/internal/db/wasmsqlite/ddlmod.go
deleted file mode 100644
index 8426080..0000000
--- a/internal/db/wasmsqlite/ddlmod.go
+++ /dev/null
@@ -1,234 +0,0 @@
-package wasmsqlite
-
-import (
- "database/sql"
- "errors"
- "fmt"
- "regexp"
- "strconv"
- "strings"
-
- "gorm.io/gorm/migrator"
-)
-
-var (
- sqliteSeparator = "`|\"|'|\t"
- indexRegexp = regexp.MustCompile(fmt.Sprintf("(?is)CREATE(?: UNIQUE)? INDEX [%v]?[\\w\\d-]+[%v]? ON (.*)$", sqliteSeparator, sqliteSeparator))
- tableRegexp = regexp.MustCompile(fmt.Sprintf("(?is)(CREATE TABLE [%v]?[\\w\\d-]+[%v]?)(?: \\((.*)\\))?", sqliteSeparator, sqliteSeparator))
- separatorRegexp = regexp.MustCompile(fmt.Sprintf("[%v]", sqliteSeparator))
- columnsRegexp = regexp.MustCompile(fmt.Sprintf("\\([%v]?([\\w\\d]+)[%v]?(?:,[%v]?([\\w\\d]+)[%v]){0,}\\)", sqliteSeparator, sqliteSeparator, sqliteSeparator, sqliteSeparator))
- columnRegexp = regexp.MustCompile(fmt.Sprintf("^[%v]?([\\w\\d]+)[%v]?\\s+([\\w\\(\\)\\d]+)(.*)$", sqliteSeparator, sqliteSeparator))
- defaultValueRegexp = regexp.MustCompile("(?i) DEFAULT \\(?(.+)?\\)?( |COLLATE|GENERATED|$)")
- regRealDataType = regexp.MustCompile(`[^\d](\d+)[^\d]?`)
-)
-
-type ddl struct {
- head string
- fields []string
- columns []migrator.ColumnType
-}
-
-func parseDDL(strs ...string) (*ddl, error) {
- var result ddl
- for _, str := range strs {
- if sections := tableRegexp.FindStringSubmatch(str); len(sections) > 0 {
- var (
- ddlBody = sections[2]
- ddlBodyRunes = []rune(ddlBody)
- bracketLevel int
- quote rune
- buf string
- )
- ddlBodyRunesLen := len(ddlBodyRunes)
-
- result.head = sections[1]
-
- for idx := 0; idx < ddlBodyRunesLen; idx++ {
- var (
- next rune = 0
- c = ddlBodyRunes[idx]
- )
- if idx+1 < ddlBodyRunesLen {
- next = ddlBodyRunes[idx+1]
- }
-
- if sc := string(c); separatorRegexp.MatchString(sc) {
- if c == next {
- buf += sc // Skip escaped quote
- idx++
- } else if quote > 0 {
- quote = 0
- } else {
- quote = c
- }
- } else if quote == 0 {
- if c == '(' {
- bracketLevel++
- } else if c == ')' {
- bracketLevel--
- } else if bracketLevel == 0 {
- if c == ',' {
- result.fields = append(result.fields, strings.TrimSpace(buf))
- buf = ""
- continue
- }
- }
- }
-
- if bracketLevel < 0 {
- return nil, errors.New("invalid DDL, unbalanced brackets")
- }
-
- buf += string(c)
- }
-
- if bracketLevel != 0 {
- return nil, errors.New("invalid DDL, unbalanced brackets")
- }
-
- if buf != "" {
- result.fields = append(result.fields, strings.TrimSpace(buf))
- }
-
- for _, f := range result.fields {
- fUpper := strings.ToUpper(f)
- if strings.HasPrefix(fUpper, "CHECK") ||
- strings.HasPrefix(fUpper, "CONSTRAINT") {
- continue
- }
-
- if strings.HasPrefix(fUpper, "PRIMARY KEY") {
- matches := columnsRegexp.FindStringSubmatch(f)
- if len(matches) > 1 {
- for _, name := range matches[1:] {
- for idx, column := range result.columns {
- if column.NameValue.String == name {
- column.PrimaryKeyValue = sql.NullBool{Bool: true, Valid: true}
- result.columns[idx] = column
- break
- }
- }
- }
- }
- } else if matches := columnRegexp.FindStringSubmatch(f); len(matches) > 0 {
- columnType := migrator.ColumnType{
- NameValue: sql.NullString{String: matches[1], Valid: true},
- DataTypeValue: sql.NullString{String: matches[2], Valid: true},
- ColumnTypeValue: sql.NullString{String: matches[2], Valid: true},
- PrimaryKeyValue: sql.NullBool{Valid: true},
- UniqueValue: sql.NullBool{Valid: true},
- NullableValue: sql.NullBool{Valid: true},
- DefaultValueValue: sql.NullString{Valid: false},
- }
-
- matchUpper := strings.ToUpper(matches[3])
- if strings.Contains(matchUpper, " NOT NULL") {
- columnType.NullableValue = sql.NullBool{Bool: false, Valid: true}
- } else if strings.Contains(matchUpper, " NULL") {
- columnType.NullableValue = sql.NullBool{Bool: true, Valid: true}
- }
- if strings.Contains(matchUpper, " UNIQUE") {
- columnType.UniqueValue = sql.NullBool{Bool: true, Valid: true}
- }
- if strings.Contains(matchUpper, " PRIMARY") {
- columnType.PrimaryKeyValue = sql.NullBool{Bool: true, Valid: true}
- }
- if defaultMatches := defaultValueRegexp.FindStringSubmatch(matches[3]); len(defaultMatches) > 1 {
- if strings.ToLower(defaultMatches[1]) != "null" {
- columnType.DefaultValueValue = sql.NullString{String: strings.Trim(defaultMatches[1], `"`), Valid: true}
- }
- }
-
- // data type length
- matches := regRealDataType.FindAllStringSubmatch(columnType.DataTypeValue.String, -1)
- if len(matches) == 1 && len(matches[0]) == 2 {
- size, _ := strconv.Atoi(matches[0][1])
- columnType.LengthValue = sql.NullInt64{Valid: true, Int64: int64(size)}
- columnType.DataTypeValue.String = strings.TrimSuffix(columnType.DataTypeValue.String, matches[0][0])
- }
-
- result.columns = append(result.columns, columnType)
- }
- }
- } else if matches := indexRegexp.FindStringSubmatch(str); len(matches) > 0 {
- if columns := columnsRegexp.FindStringSubmatch(matches[1]); len(columns) == 1 {
- for idx, c := range result.columns {
- if c.NameValue.String == columns[0] {
- c.UniqueValue = sql.NullBool{Bool: true, Valid: true}
- result.columns[idx] = c
- }
- }
- }
- } else {
- return nil, errors.New("invalid DDL")
- }
- }
-
- return &result, nil
-}
-
-func (d *ddl) compile() string {
- if len(d.fields) == 0 {
- return d.head
- }
-
- return fmt.Sprintf("%s (%s)", d.head, strings.Join(d.fields, ","))
-}
-
-func (d *ddl) addConstraint(name string, sql string) {
- reg := regexp.MustCompile("^CONSTRAINT [\"`]?" + regexp.QuoteMeta(name) + "[\"` ]")
-
- for i := 0; i < len(d.fields); i++ {
- if reg.MatchString(d.fields[i]) {
- d.fields[i] = sql
- return
- }
- }
-
- d.fields = append(d.fields, sql)
-}
-
-func (d *ddl) removeConstraint(name string) bool {
- reg := regexp.MustCompile("^CONSTRAINT [\"`]?" + regexp.QuoteMeta(name) + "[\"` ]")
-
- for i := 0; i < len(d.fields); i++ {
- if reg.MatchString(d.fields[i]) {
- d.fields = append(d.fields[:i], d.fields[i+1:]...)
- return true
- }
- }
- return false
-}
-
-func (d *ddl) hasConstraint(name string) bool {
- reg := regexp.MustCompile("^CONSTRAINT [\"`]?" + regexp.QuoteMeta(name) + "[\"` ]")
-
- for _, f := range d.fields {
- if reg.MatchString(f) {
- return true
- }
- }
- return false
-}
-
-func (d *ddl) getColumns() []string {
- res := []string{}
-
- for _, f := range d.fields {
- fUpper := strings.ToUpper(f)
- if strings.HasPrefix(fUpper, "PRIMARY KEY") ||
- strings.HasPrefix(fUpper, "CHECK") ||
- strings.HasPrefix(fUpper, "CONSTRAINT") ||
- strings.Contains(fUpper, "GENERATED ALWAYS AS") {
- continue
- }
-
- reg := regexp.MustCompile("^[\"`']?([\\w\\d]+)[\"`']?")
- match := reg.FindStringSubmatch(f)
-
- if match != nil {
- res = append(res, "`"+match[1]+"`")
- }
- }
- return res
-}
diff --git a/internal/db/wasmsqlite/ddlmod_test.go b/internal/db/wasmsqlite/ddlmod_test.go
deleted file mode 100644
index 399c358..0000000
--- a/internal/db/wasmsqlite/ddlmod_test.go
+++ /dev/null
@@ -1,247 +0,0 @@
-package wasmsqlite
-
-import (
- "database/sql"
- "testing"
-
- "gorm.io/gorm/migrator"
- "gorm.io/gorm/utils/tests"
-)
-
-func TestParseDDL(t *testing.T) {
- params := []struct {
- name string
- sql []string
- nFields int
- columns []migrator.ColumnType
- }{
- {"with_fk", []string{
- "CREATE TABLE `notes` (`id` integer NOT NULL,`text` varchar(500) DEFAULT \"hello\",`age` integer DEFAULT 18,`user_id` integer,PRIMARY KEY (`id`),CONSTRAINT `fk_users_notes` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`))",
- "CREATE UNIQUE INDEX `idx_profiles_refer` ON `profiles`(`text`)",
- }, 6, []migrator.ColumnType{
- {NameValue: sql.NullString{String: "id", Valid: true}, DataTypeValue: sql.NullString{String: "integer", Valid: true}, ColumnTypeValue: sql.NullString{String: "integer", Valid: true}, PrimaryKeyValue: sql.NullBool{Bool: true, Valid: true}, NullableValue: sql.NullBool{Valid: true}, UniqueValue: sql.NullBool{Valid: true}, DefaultValueValue: sql.NullString{Valid: false}},
- {NameValue: sql.NullString{String: "text", Valid: true}, DataTypeValue: sql.NullString{String: "varchar", Valid: true}, LengthValue: sql.NullInt64{Int64: 500, Valid: true}, ColumnTypeValue: sql.NullString{String: "varchar(500)", Valid: true}, DefaultValueValue: sql.NullString{String: "hello", Valid: true}, NullableValue: sql.NullBool{Valid: true}, UniqueValue: sql.NullBool{Valid: true}, PrimaryKeyValue: sql.NullBool{Valid: true}},
- {NameValue: sql.NullString{String: "age", Valid: true}, DataTypeValue: sql.NullString{String: "integer", Valid: true}, ColumnTypeValue: sql.NullString{String: "integer", Valid: true}, DefaultValueValue: sql.NullString{String: "18", Valid: true}, NullableValue: sql.NullBool{Valid: true}, UniqueValue: sql.NullBool{Valid: true}, PrimaryKeyValue: sql.NullBool{Valid: true}},
- {NameValue: sql.NullString{String: "user_id", Valid: true}, DataTypeValue: sql.NullString{String: "integer", Valid: true}, ColumnTypeValue: sql.NullString{String: "integer", Valid: true}, DefaultValueValue: sql.NullString{Valid: false}, NullableValue: sql.NullBool{Valid: true}, UniqueValue: sql.NullBool{Valid: true}, PrimaryKeyValue: sql.NullBool{Valid: true}},
- },
- },
- {"with_check", []string{"CREATE TABLE Persons (ID int NOT NULL,LastName varchar(255) NOT NULL,FirstName varchar(255),Age int,CHECK (Age>=18),CHECK (FirstName<>'John'))"}, 6, []migrator.ColumnType{
- {NameValue: sql.NullString{String: "ID", Valid: true}, DataTypeValue: sql.NullString{String: "int", Valid: true}, ColumnTypeValue: sql.NullString{String: "int", Valid: true}, NullableValue: sql.NullBool{Valid: true}, DefaultValueValue: sql.NullString{Valid: false}, UniqueValue: sql.NullBool{Valid: true}, PrimaryKeyValue: sql.NullBool{Valid: true}},
- {NameValue: sql.NullString{String: "LastName", Valid: true}, DataTypeValue: sql.NullString{String: "varchar", Valid: true}, LengthValue: sql.NullInt64{Int64: 255, Valid: true}, ColumnTypeValue: sql.NullString{String: "varchar(255)", Valid: true}, NullableValue: sql.NullBool{Bool: false, Valid: true}, DefaultValueValue: sql.NullString{Valid: false}, UniqueValue: sql.NullBool{Valid: true}, PrimaryKeyValue: sql.NullBool{Valid: true}},
- {NameValue: sql.NullString{String: "FirstName", Valid: true}, DataTypeValue: sql.NullString{String: "varchar", Valid: true}, LengthValue: sql.NullInt64{Int64: 255, Valid: true}, ColumnTypeValue: sql.NullString{String: "varchar(255)", Valid: true}, DefaultValueValue: sql.NullString{Valid: false}, NullableValue: sql.NullBool{Valid: true}, UniqueValue: sql.NullBool{Valid: true}, PrimaryKeyValue: sql.NullBool{Valid: true}},
- {NameValue: sql.NullString{String: "Age", Valid: true}, DataTypeValue: sql.NullString{String: "int", Valid: true}, ColumnTypeValue: sql.NullString{String: "int", Valid: true}, DefaultValueValue: sql.NullString{Valid: false}, NullableValue: sql.NullBool{Valid: true}, UniqueValue: sql.NullBool{Valid: true}, PrimaryKeyValue: sql.NullBool{Valid: true}},
- }},
- {"lowercase", []string{"create table test (ID int NOT NULL)"}, 1, []migrator.ColumnType{
- {NameValue: sql.NullString{String: "ID", Valid: true}, DataTypeValue: sql.NullString{String: "int", Valid: true}, ColumnTypeValue: sql.NullString{String: "int", Valid: true}, NullableValue: sql.NullBool{Bool: false, Valid: true}, DefaultValueValue: sql.NullString{Valid: false}, UniqueValue: sql.NullBool{Valid: true}, PrimaryKeyValue: sql.NullBool{Valid: true}},
- },
- },
- {"no brackets", []string{"create table test"}, 0, nil},
- {"with_special_characters", []string{
- "CREATE TABLE `test` (`text` varchar(10) DEFAULT \"测试, \")",
- }, 1, []migrator.ColumnType{
- {NameValue: sql.NullString{String: "text", Valid: true}, DataTypeValue: sql.NullString{String: "varchar", Valid: true}, LengthValue: sql.NullInt64{Int64: 10, Valid: true}, ColumnTypeValue: sql.NullString{String: "varchar(10)", Valid: true}, DefaultValueValue: sql.NullString{String: "测试, ", Valid: true}, NullableValue: sql.NullBool{Valid: true}, UniqueValue: sql.NullBool{Valid: true}, PrimaryKeyValue: sql.NullBool{Valid: true}},
- },
- },
- {
- "table_name_with_dash",
- []string{
- "CREATE TABLE `test-a` (`id` int NOT NULL)",
- "CREATE UNIQUE INDEX `idx_test-a_id` ON `test-a`(`id`)",
- },
- 1,
- []migrator.ColumnType{
- {
- NameValue: sql.NullString{String: "id", Valid: true},
- DataTypeValue: sql.NullString{String: "int", Valid: true},
- ColumnTypeValue: sql.NullString{String: "int", Valid: true},
- NullableValue: sql.NullBool{Bool: false, Valid: true},
- DefaultValueValue: sql.NullString{Valid: false},
- UniqueValue: sql.NullBool{Valid: true},
- PrimaryKeyValue: sql.NullBool{Valid: true},
- },
- },
- },
- }
-
- for _, p := range params {
- t.Run(p.name, func(t *testing.T) {
- ddl, err := parseDDL(p.sql...)
-
- if err != nil {
- panic(err.Error())
- }
-
- tests.AssertEqual(t, p.sql[0], ddl.compile())
- if len(ddl.fields) != p.nFields {
- t.Fatalf("fields length doesn't match: expect: %v, got %v", p.nFields, len(ddl.fields))
- }
- tests.AssertEqual(t, ddl.columns, p.columns)
- })
- }
-}
-
-func TestParseDDL_error(t *testing.T) {
- params := []struct {
- name string
- sql string
- }{
- {"invalid_cmd", "CREATE TABLE"},
- {"unbalanced_brackets", "CREATE TABLE test (ID int NOT NULL,Name varchar(255)"},
- {"unbalanced_brackets2", "CREATE TABLE test (ID int NOT NULL,Name varchar(255)))"},
- }
-
- for _, p := range params {
- t.Run(p.name, func(t *testing.T) {
- _, err := parseDDL(p.sql)
- if err == nil {
- t.Fail()
- }
- })
- }
-}
-
-func TestAddConstraint(t *testing.T) {
- params := []struct {
- name string
- fields []string
- cName string
- sql string
- expect []string
- }{
- {
- name: "add_new",
- fields: []string{"`id` integer NOT NULL"},
- cName: "fk_users_notes",
- sql: "CONSTRAINT `fk_users_notes` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`))",
- expect: []string{"`id` integer NOT NULL", "CONSTRAINT `fk_users_notes` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`))"},
- },
- {
- name: "update",
- fields: []string{"`id` integer NOT NULL", "CONSTRAINT `fk_users_notes` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`))"},
- cName: "fk_users_notes",
- sql: "CONSTRAINT `fk_users_notes` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`)) ON UPDATE CASCADE ON DELETE CASCADE",
- expect: []string{"`id` integer NOT NULL", "CONSTRAINT `fk_users_notes` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`)) ON UPDATE CASCADE ON DELETE CASCADE"},
- },
- {
- name: "add_check",
- fields: []string{"`id` integer NOT NULL"},
- cName: "name_checker",
- sql: "CONSTRAINT `name_checker` CHECK (`name` <> 'jinzhu')",
- expect: []string{"`id` integer NOT NULL", "CONSTRAINT `name_checker` CHECK (`name` <> 'jinzhu')"},
- },
- {
- name: "update_check",
- fields: []string{"`id` integer NOT NULL", "CONSTRAINT `name_checker` CHECK (`name` <> 'thetadev')"},
- cName: "name_checker",
- sql: "CONSTRAINT `name_checker` CHECK (`name` <> 'jinzhu')",
- expect: []string{"`id` integer NOT NULL", "CONSTRAINT `name_checker` CHECK (`name` <> 'jinzhu')"},
- },
- }
-
- for _, p := range params {
- t.Run(p.name, func(t *testing.T) {
- testDDL := ddl{fields: p.fields}
-
- testDDL.addConstraint(p.cName, p.sql)
- tests.AssertEqual(t, p.expect, testDDL.fields)
- })
- }
-}
-
-func TestRemoveConstraint(t *testing.T) {
- params := []struct {
- name string
- fields []string
- cName string
- success bool
- expect []string
- }{
- {
- name: "fk",
- fields: []string{"`id` integer NOT NULL", "CONSTRAINT `fk_users_notes` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`))"},
- cName: "fk_users_notes",
- success: true,
- expect: []string{"`id` integer NOT NULL"},
- },
- {
- name: "check",
- fields: []string{"CONSTRAINT `name_checker` CHECK (`name` <> 'thetadev')", "`id` integer NOT NULL"},
- cName: "name_checker",
- success: true,
- expect: []string{"`id` integer NOT NULL"},
- },
- {
- name: "none",
- fields: []string{"CONSTRAINT `name_checker` CHECK (`name` <> 'thetadev')", "`id` integer NOT NULL"},
- cName: "nothing",
- success: false,
- expect: []string{"CONSTRAINT `name_checker` CHECK (`name` <> 'thetadev')", "`id` integer NOT NULL"},
- },
- }
-
- for _, p := range params {
- t.Run(p.name, func(t *testing.T) {
- testDDL := ddl{fields: p.fields}
-
- success := testDDL.removeConstraint(p.cName)
-
- tests.AssertEqual(t, p.success, success)
- tests.AssertEqual(t, p.expect, testDDL.fields)
- })
- }
-}
-
-func TestGetColumns(t *testing.T) {
- params := []struct {
- name string
- ddl string
- columns []string
- }{
- {
- name: "with_fk",
- ddl: "CREATE TABLE `notes` (`id` integer NOT NULL,`text` varchar(500),`user_id` integer,PRIMARY KEY (`id`),CONSTRAINT `fk_users_notes` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`))",
- columns: []string{"`id`", "`text`", "`user_id`"},
- },
- {
- name: "with_check",
- ddl: "CREATE TABLE Persons (ID int NOT NULL,LastName varchar(255) NOT NULL,FirstName varchar(255),Age int,CHECK (Age>=18),CHECK (FirstName!='John'))",
- columns: []string{"`ID`", "`LastName`", "`FirstName`", "`Age`"},
- },
- {
- name: "with_escaped_quote",
- ddl: "CREATE TABLE Persons (ID int NOT NULL,LastName varchar(255) NOT NULL DEFAULT \"\",FirstName varchar(255))",
- columns: []string{"`ID`", "`LastName`", "`FirstName`"},
- },
- {
- name: "with_generated_column",
- ddl: "CREATE TABLE Persons (ID int NOT NULL,LastName varchar(255) NOT NULL,FirstName varchar(255),FullName varchar(255) GENERATED ALWAYS AS (FirstName || ' ' || LastName))",
- columns: []string{"`ID`", "`LastName`", "`FirstName`"},
- },
- {
- name: "with_new_line",
- ddl: `CREATE TABLE "tb_sys_role_menu__temp" (
- "id" integer PRIMARY KEY AUTOINCREMENT,
- "created_at" datetime NOT NULL,
- "updated_at" datetime NOT NULL,
- "created_by" integer NOT NULL DEFAULT 0,
- "updated_by" integer NOT NULL DEFAULT 0,
- "role_id" integer NOT NULL,
- "menu_id" bigint NOT NULL
-)`,
- columns: []string{"`id`", "`created_at`", "`updated_at`", "`created_by`", "`updated_by`", "`role_id`", "`menu_id`"},
- },
- }
-
- for _, p := range params {
- t.Run(p.name, func(t *testing.T) {
- testDDL, err := parseDDL(p.ddl)
- if err != nil {
- panic(err.Error())
- }
-
- cols := testDDL.getColumns()
-
- tests.AssertEqual(t, p.columns, cols)
- })
- }
-}
diff --git a/internal/db/wasmsqlite/errors.go b/internal/db/wasmsqlite/errors.go
deleted file mode 100644
index cb6c61b..0000000
--- a/internal/db/wasmsqlite/errors.go
+++ /dev/null
@@ -1,7 +0,0 @@
-package wasmsqlite
-
-import "errors"
-
-var (
- ErrConstraintsNotImplemented = errors.New("constraints not implemented on sqlite, consider using DisableForeignKeyConstraintWhenMigrating, more details https://github.com/go-gorm/gorm/wiki/GORM-V2-Release-Note-Draft#all-new-migrator")
-)
diff --git a/internal/db/wasmsqlite/migrator.go b/internal/db/wasmsqlite/migrator.go
deleted file mode 100644
index 0ea6eef..0000000
--- a/internal/db/wasmsqlite/migrator.go
+++ /dev/null
@@ -1,423 +0,0 @@
-package wasmsqlite
-
-import (
- "database/sql"
- "fmt"
- "regexp"
- "strings"
-
- "gorm.io/gorm"
- "gorm.io/gorm/clause"
- "gorm.io/gorm/migrator"
- "gorm.io/gorm/schema"
-)
-
-type Migrator struct {
- migrator.Migrator
-}
-
-func (m *Migrator) RunWithoutForeignKey(fc func() error) error {
- var enabled int
- m.DB.Raw("PRAGMA foreign_keys").Scan(&enabled)
- if enabled == 1 {
- m.DB.Exec("PRAGMA foreign_keys = OFF")
- defer m.DB.Exec("PRAGMA foreign_keys = ON")
- }
-
- return fc()
-}
-
-func (m Migrator) HasTable(value interface{}) bool {
- var count int
- m.Migrator.RunWithValue(value, func(stmt *gorm.Statement) error {
- return m.DB.Raw("SELECT count(*) FROM sqlite_master WHERE type='table' AND name=?", stmt.Table).Row().Scan(&count)
- })
- return count > 0
-}
-
-func (m Migrator) DropTable(values ...interface{}) error {
- return m.RunWithoutForeignKey(func() error {
- values = m.ReorderModels(values, false)
- tx := m.DB.Session(&gorm.Session{})
-
- for i := len(values) - 1; i >= 0; i-- {
- if err := m.RunWithValue(values[i], func(stmt *gorm.Statement) error {
- return tx.Exec("DROP TABLE IF EXISTS ?", clause.Table{Name: stmt.Table}).Error
- }); err != nil {
- return err
- }
- }
-
- return nil
- })
-}
-
-func (m Migrator) GetTables() (tableList []string, err error) {
- return tableList, m.DB.Raw("SELECT name FROM sqlite_master where type=?", "table").Scan(&tableList).Error
-}
-
-func (m Migrator) HasColumn(value interface{}, name string) bool {
- var count int
- m.Migrator.RunWithValue(value, func(stmt *gorm.Statement) error {
- if stmt.Schema != nil {
- if field := stmt.Schema.LookUpField(name); field != nil {
- name = field.DBName
- }
- }
-
- if name != "" {
- m.DB.Raw(
- "SELECT count(*) FROM sqlite_master WHERE type = ? AND tbl_name = ? AND (sql LIKE ? OR sql LIKE ? OR sql LIKE ? OR sql LIKE ? OR sql LIKE ?)",
- "table", stmt.Table, `%"`+name+`" %`, `%`+name+` %`, "%`"+name+"`%", "%["+name+"]%", "%\t"+name+"\t%",
- ).Row().Scan(&count)
- }
- return nil
- })
- return count > 0
-}
-
-func (m Migrator) AlterColumn(value interface{}, name string) error {
- return m.RunWithoutForeignKey(func() error {
- return m.recreateTable(value, nil, func(rawDDL string, stmt *gorm.Statement) (sql string, sqlArgs []interface{}, err error) {
- if field := stmt.Schema.LookUpField(name); field != nil {
- // lookup field from table definition, ddl might looks like `'name' int,` or `'name' int)`
- reg, err := regexp.Compile("(`|'|\"| )" + field.DBName + "(`|'|\"| ) .*?(,|\\)\\s*$)")
- if err != nil {
- return "", nil, err
- }
-
- createSQL := reg.ReplaceAllString(rawDDL, fmt.Sprintf("`%v` ?$3", field.DBName))
-
- if createSQL == rawDDL {
- return "", nil, fmt.Errorf("failed to look up field %v from DDL %v", field.DBName, rawDDL)
- }
-
- return createSQL, []interface{}{m.FullDataTypeOf(field)}, nil
- }
- return "", nil, fmt.Errorf("failed to alter field with name %v", name)
- })
- })
-}
-
-// ColumnTypes return columnTypes []gorm.ColumnType and execErr error
-func (m Migrator) ColumnTypes(value interface{}) ([]gorm.ColumnType, error) {
- columnTypes := make([]gorm.ColumnType, 0)
- execErr := m.RunWithValue(value, func(stmt *gorm.Statement) (err error) {
- var (
- sqls []string
- sqlDDL *ddl
- )
-
- if err := m.DB.Raw("SELECT sql FROM sqlite_master WHERE type IN ? AND tbl_name = ? AND sql IS NOT NULL order by type = ? desc", []string{"table", "index"}, stmt.Table, "table").Scan(&sqls).Error; err != nil {
- return err
- }
-
- if sqlDDL, err = parseDDL(sqls...); err != nil {
- return err
- }
-
- rows, err := m.DB.Session(&gorm.Session{}).Table(stmt.Table).Limit(1).Rows()
- if err != nil {
- return err
- }
- defer func() {
- err = rows.Close()
- }()
-
- var rawColumnTypes []*sql.ColumnType
- rawColumnTypes, err = rows.ColumnTypes()
- if err != nil {
- return err
- }
-
- for _, c := range rawColumnTypes {
- columnType := migrator.ColumnType{SQLColumnType: c}
- for _, column := range sqlDDL.columns {
- if column.NameValue.String == c.Name() {
- column.SQLColumnType = c
- columnType = column
- break
- }
- }
- columnTypes = append(columnTypes, columnType)
- }
-
- return err
- })
-
- return columnTypes, execErr
-}
-
-func (m Migrator) DropColumn(value interface{}, name string) error {
- return m.recreateTable(value, nil, func(rawDDL string, stmt *gorm.Statement) (sql string, sqlArgs []interface{}, err error) {
- if field := stmt.Schema.LookUpField(name); field != nil {
- name = field.DBName
- }
-
- reg, err := regexp.Compile("(`|'|\"| |\\[)" + name + "(`|'|\"| |\\]) .*?,")
- if err != nil {
- return "", nil, err
- }
-
- createSQL := reg.ReplaceAllString(rawDDL, "")
-
- return createSQL, nil, nil
- })
-}
-
-func (m Migrator) CreateConstraint(value interface{}, name string) error {
- return m.RunWithValue(value, func(stmt *gorm.Statement) error {
- constraint, chk, table := m.GuessConstraintAndTable(stmt, name)
-
- return m.recreateTable(value, &table,
- func(rawDDL string, stmt *gorm.Statement) (sql string, sqlArgs []interface{}, err error) {
- var (
- constraintName string
- constraintSql string
- constraintValues []interface{}
- )
-
- if constraint != nil {
- constraintName = constraint.Name
- constraintSql, constraintValues = buildConstraint(constraint)
- } else if chk != nil {
- constraintName = chk.Name
- constraintSql = "CONSTRAINT ? CHECK (?)"
- constraintValues = []interface{}{clause.Column{Name: chk.Name}, clause.Expr{SQL: chk.Constraint}}
- } else {
- return "", nil, nil
- }
-
- createDDL, err := parseDDL(rawDDL)
- if err != nil {
- return "", nil, err
- }
- createDDL.addConstraint(constraintName, constraintSql)
- createSQL := createDDL.compile()
-
- return createSQL, constraintValues, nil
- })
- })
-}
-
-func (m Migrator) DropConstraint(value interface{}, name string) error {
- return m.RunWithValue(value, func(stmt *gorm.Statement) error {
- constraint, chk, table := m.GuessConstraintAndTable(stmt, name)
- if constraint != nil {
- name = constraint.Name
- } else if chk != nil {
- name = chk.Name
- }
-
- return m.recreateTable(value, &table,
- func(rawDDL string, stmt *gorm.Statement) (sql string, sqlArgs []interface{}, err error) {
- createDDL, err := parseDDL(rawDDL)
- if err != nil {
- return "", nil, err
- }
- createDDL.removeConstraint(name)
- createSQL := createDDL.compile()
-
- return createSQL, nil, nil
- })
- })
-}
-
-func (m Migrator) HasConstraint(value interface{}, name string) bool {
- var count int64
- m.RunWithValue(value, func(stmt *gorm.Statement) error {
- constraint, chk, table := m.GuessConstraintAndTable(stmt, name)
- if constraint != nil {
- name = constraint.Name
- } else if chk != nil {
- name = chk.Name
- }
-
- m.DB.Raw(
- "SELECT count(*) FROM sqlite_master WHERE type = ? AND tbl_name = ? AND (sql LIKE ? OR sql LIKE ? OR sql LIKE ? OR sql LIKE ? OR sql LIKE ?)",
- "table", table, `%CONSTRAINT "`+name+`" %`, `%CONSTRAINT `+name+` %`, "%CONSTRAINT `"+name+"`%", "%CONSTRAINT ["+name+"]%", "%CONSTRAINT \t"+name+"\t%",
- ).Row().Scan(&count)
-
- return nil
- })
-
- return count > 0
-}
-
-func (m Migrator) CurrentDatabase() (name string) {
- var null interface{}
- m.DB.Raw("PRAGMA database_list").Row().Scan(&null, &name, &null)
- return
-}
-
-func (m Migrator) BuildIndexOptions(opts []schema.IndexOption, stmt *gorm.Statement) (results []interface{}) {
- for _, opt := range opts {
- str := stmt.Quote(opt.DBName)
- if opt.Expression != "" {
- str = opt.Expression
- }
-
- if opt.Collate != "" {
- str += " COLLATE " + opt.Collate
- }
-
- if opt.Sort != "" {
- str += " " + opt.Sort
- }
- results = append(results, clause.Expr{SQL: str})
- }
- return
-}
-
-func (m Migrator) CreateIndex(value interface{}, name string) error {
- return m.RunWithValue(value, func(stmt *gorm.Statement) error {
- if idx := stmt.Schema.LookIndex(name); idx != nil {
- opts := m.BuildIndexOptions(idx.Fields, stmt)
- values := []interface{}{clause.Column{Name: idx.Name}, clause.Table{Name: stmt.Table}, opts}
-
- createIndexSQL := "CREATE "
- if idx.Class != "" {
- createIndexSQL += idx.Class + " "
- }
- createIndexSQL += "INDEX ?"
-
- if idx.Type != "" {
- createIndexSQL += " USING " + idx.Type
- }
- createIndexSQL += " ON ??"
-
- if idx.Where != "" {
- createIndexSQL += " WHERE " + idx.Where
- }
-
- return m.DB.Exec(createIndexSQL, values...).Error
- }
-
- return fmt.Errorf("failed to create index with name %v", name)
- })
-}
-
-func (m Migrator) HasIndex(value interface{}, name string) bool {
- var count int
- m.RunWithValue(value, func(stmt *gorm.Statement) error {
- if idx := stmt.Schema.LookIndex(name); idx != nil {
- name = idx.Name
- }
-
- if name != "" {
- m.DB.Raw(
- "SELECT count(*) FROM sqlite_master WHERE type = ? AND tbl_name = ? AND name = ?", "index", stmt.Table, name,
- ).Row().Scan(&count)
- }
- return nil
- })
- return count > 0
-}
-
-func (m Migrator) RenameIndex(value interface{}, oldName, newName string) error {
- return m.RunWithValue(value, func(stmt *gorm.Statement) error {
- var sql string
- m.DB.Raw("SELECT sql FROM sqlite_master WHERE type = ? AND tbl_name = ? AND name = ?", "index", stmt.Table, oldName).Row().Scan(&sql)
- if sql != "" {
- return m.DB.Exec(strings.Replace(sql, oldName, newName, 1)).Error
- }
- return fmt.Errorf("failed to find index with name %v", oldName)
- })
-}
-
-func (m Migrator) DropIndex(value interface{}, name string) error {
- return m.RunWithValue(value, func(stmt *gorm.Statement) error {
- if idx := stmt.Schema.LookIndex(name); idx != nil {
- name = idx.Name
- }
-
- return m.DB.Exec("DROP INDEX ?", clause.Column{Name: name}).Error
- })
-}
-
-func buildConstraint(constraint *schema.Constraint) (sql string, results []interface{}) {
- sql = "CONSTRAINT ? FOREIGN KEY ? REFERENCES ??"
- if constraint.OnDelete != "" {
- sql += " ON DELETE " + constraint.OnDelete
- }
-
- if constraint.OnUpdate != "" {
- sql += " ON UPDATE " + constraint.OnUpdate
- }
-
- var foreignKeys, references []interface{}
- for _, field := range constraint.ForeignKeys {
- foreignKeys = append(foreignKeys, clause.Column{Name: field.DBName})
- }
-
- for _, field := range constraint.References {
- references = append(references, clause.Column{Name: field.DBName})
- }
- results = append(results, clause.Table{Name: constraint.Name}, foreignKeys, clause.Table{Name: constraint.ReferenceSchema.Table}, references)
- return
-}
-
-func (m Migrator) getRawDDL(table string) (string, error) {
- var createSQL string
- m.DB.Raw("SELECT sql FROM sqlite_master WHERE type = ? AND tbl_name = ? AND name = ?", "table", table, table).Row().Scan(&createSQL)
-
- if m.DB.Error != nil {
- return "", m.DB.Error
- }
- return createSQL, nil
-}
-
-func (m Migrator) recreateTable(value interface{}, tablePtr *string,
- getCreateSQL func(rawDDL string, stmt *gorm.Statement) (sql string, sqlArgs []interface{}, err error)) error {
- return m.RunWithValue(value, func(stmt *gorm.Statement) error {
- table := stmt.Table
- if tablePtr != nil {
- table = *tablePtr
- }
-
- rawDDL, err := m.getRawDDL(table)
- if err != nil {
- return err
- }
-
- newTableName := table + "__temp"
-
- createSQL, sqlArgs, err := getCreateSQL(rawDDL, stmt)
- if err != nil {
- return err
- }
- if createSQL == "" {
- return nil
- }
-
- tableReg, err := regexp.Compile(" ('|`|\"| )" + table + "('|`|\"| ) ")
- if err != nil {
- return err
- }
- createSQL = tableReg.ReplaceAllString(createSQL, fmt.Sprintf(" `%v` ", newTableName))
-
- createDDL, err := parseDDL(createSQL)
- if err != nil {
- return err
- }
- columns := createDDL.getColumns()
-
- return m.DB.Transaction(func(tx *gorm.DB) error {
- if err := tx.Exec(createSQL, sqlArgs...).Error; err != nil {
- return err
- }
-
- queries := []string{
- fmt.Sprintf("INSERT INTO `%v`(%v) SELECT %v FROM `%v`", newTableName, strings.Join(columns, ","), strings.Join(columns, ","), table),
- fmt.Sprintf("DROP TABLE `%v`", table),
- fmt.Sprintf("ALTER TABLE `%v` RENAME TO `%v`", newTableName, table),
- }
- for _, query := range queries {
- if err := tx.Exec(query).Error; err != nil {
- return err
- }
- }
- return nil
- })
- })
-}
diff --git a/internal/db/wasmsqlite/sqlite.go b/internal/db/wasmsqlite/sqlite.go
deleted file mode 100644
index 2368ce7..0000000
--- a/internal/db/wasmsqlite/sqlite.go
+++ /dev/null
@@ -1,224 +0,0 @@
-package wasmsqlite
-
-import (
- "context"
- "database/sql"
- "strconv"
- "strings"
-
- "gorm.io/gorm/callbacks"
-
- _ "github.com/ncruces/go-sqlite3"
- _ "github.com/ncruces/go-sqlite3/driver"
- _ "github.com/ncruces/go-sqlite3/embed"
-
- "gorm.io/gorm"
- "gorm.io/gorm/clause"
- "gorm.io/gorm/logger"
- "gorm.io/gorm/migrator"
- "gorm.io/gorm/schema"
-)
-
-// DriverName is the default driver name for SQLite.
-const DriverName = "sqlite3"
-
-type Dialector struct {
- DriverName string
- DSN string
- Conn gorm.ConnPool
-}
-
-func Open(dsn string) gorm.Dialector {
- return &Dialector{DSN: dsn}
-}
-
-func (dialector Dialector) Name() string {
- return "sqlite"
-}
-
-func (dialector Dialector) Initialize(db *gorm.DB) (err error) {
- if dialector.DriverName == "" {
- dialector.DriverName = DriverName
- }
-
- if dialector.Conn != nil {
- db.ConnPool = dialector.Conn
- } else {
- conn, err := sql.Open(dialector.DriverName, dialector.DSN)
- if err != nil {
- return err
- }
- db.ConnPool = conn
- }
-
- var version string
- if err := db.ConnPool.QueryRowContext(context.Background(), "select sqlite_version()").Scan(&version); err != nil {
- return err
- }
- // https://www.sqlite.org/releaselog/3_35_0.html
- if compareVersion(version, "3.35.0") >= 0 {
- callbacks.RegisterDefaultCallbacks(db, &callbacks.Config{
- CreateClauses: []string{"INSERT", "VALUES", "ON CONFLICT", "RETURNING"},
- UpdateClauses: []string{"UPDATE", "SET", "WHERE", "RETURNING"},
- DeleteClauses: []string{"DELETE", "FROM", "WHERE", "RETURNING"},
- LastInsertIDReversed: true,
- })
- } else {
- callbacks.RegisterDefaultCallbacks(db, &callbacks.Config{
- LastInsertIDReversed: true,
- })
- }
-
- for k, v := range dialector.ClauseBuilders() {
- db.ClauseBuilders[k] = v
- }
- return
-}
-
-func (dialector Dialector) ClauseBuilders() map[string]clause.ClauseBuilder {
- return map[string]clause.ClauseBuilder{
- "INSERT": func(c clause.Clause, builder clause.Builder) {
- if insert, ok := c.Expression.(clause.Insert); ok {
- if stmt, ok := builder.(*gorm.Statement); ok {
- stmt.WriteString("INSERT ")
- if insert.Modifier != "" {
- stmt.WriteString(insert.Modifier)
- stmt.WriteByte(' ')
- }
-
- stmt.WriteString("INTO ")
- if insert.Table.Name == "" {
- stmt.WriteQuoted(stmt.Table)
- } else {
- stmt.WriteQuoted(insert.Table)
- }
- return
- }
- }
-
- c.Build(builder)
- },
- "LIMIT": func(c clause.Clause, builder clause.Builder) {
- if limit, ok := c.Expression.(clause.Limit); ok {
- var lmt = -1
- if limit.Limit != nil && *limit.Limit >= 0 {
- lmt = *limit.Limit
- }
- if lmt >= 0 || limit.Offset > 0 {
- builder.WriteString("LIMIT ")
- builder.WriteString(strconv.Itoa(lmt))
- }
- if limit.Offset > 0 {
- builder.WriteString(" OFFSET ")
- builder.WriteString(strconv.Itoa(limit.Offset))
- }
- }
- },
- "FOR": func(c clause.Clause, builder clause.Builder) {
- if _, ok := c.Expression.(clause.Locking); ok {
- // SQLite3 does not support row-level locking.
- return
- }
- c.Build(builder)
- },
- }
-}
-
-func (dialector Dialector) DefaultValueOf(field *schema.Field) clause.Expression {
- if field.AutoIncrement {
- return clause.Expr{SQL: "NULL"}
- }
-
- // doesn't work, will raise error
- return clause.Expr{SQL: "DEFAULT"}
-}
-
-func (dialector Dialector) Migrator(db *gorm.DB) gorm.Migrator {
- return Migrator{migrator.Migrator{Config: migrator.Config{
- DB: db,
- Dialector: dialector,
- CreateIndexAfterCreateTable: true,
- }}}
-}
-
-func (dialector Dialector) BindVarTo(writer clause.Writer, stmt *gorm.Statement, v interface{}) {
- writer.WriteByte('?')
-}
-
-func (dialector Dialector) QuoteTo(writer clause.Writer, str string) {
- writer.WriteByte('`')
- if strings.Contains(str, ".") {
- for idx, str := range strings.Split(str, ".") {
- if idx > 0 {
- writer.WriteString(".`")
- }
- writer.WriteString(str)
- writer.WriteByte('`')
- }
- } else {
- writer.WriteString(str)
- writer.WriteByte('`')
- }
-}
-
-func (dialector Dialector) Explain(sql string, vars ...interface{}) string {
- return logger.ExplainSQL(sql, nil, `"`, vars...)
-}
-
-func (dialector Dialector) DataTypeOf(field *schema.Field) string {
- switch field.DataType {
- case schema.Bool:
- return "numeric"
- case schema.Int, schema.Uint:
- if field.AutoIncrement && !field.PrimaryKey {
- // https://www.sqlite.org/autoinc.html
- return "integer PRIMARY KEY AUTOINCREMENT"
- } else {
- return "integer"
- }
- case schema.Float:
- return "real"
- case schema.String:
- return "text"
- case schema.Time:
- return "datetime"
- case schema.Bytes:
- return "blob"
- }
-
- return string(field.DataType)
-}
-
-func (dialectopr Dialector) SavePoint(tx *gorm.DB, name string) error {
- tx.Exec("SAVEPOINT " + name)
- return nil
-}
-
-func (dialectopr Dialector) RollbackTo(tx *gorm.DB, name string) error {
- tx.Exec("ROLLBACK TO SAVEPOINT " + name)
- return nil
-}
-
-func compareVersion(version1, version2 string) int {
- n, m := len(version1), len(version2)
- i, j := 0, 0
- for i < n || j < m {
- x := 0
- for ; i < n && version1[i] != '.'; i++ {
- x = x*10 + int(version1[i]-'0')
- }
- i++
- y := 0
- for ; j < m && version2[j] != '.'; j++ {
- y = y*10 + int(version2[j]-'0')
- }
- j++
- if x > y {
- return 1
- }
- if x < y {
- return -1
- }
- }
- return 0
-}
diff --git a/internal/db/wasmsqlite/sqlite_test.go b/internal/db/wasmsqlite/sqlite_test.go
deleted file mode 100644
index 2a6055d..0000000
--- a/internal/db/wasmsqlite/sqlite_test.go
+++ /dev/null
@@ -1,122 +0,0 @@
-package wasmsqlite
-
-import (
- "database/sql"
- "fmt"
- "testing"
-
- _ "github.com/ncruces/go-sqlite3"
- "gorm.io/gorm"
- "modernc.org/sqlite"
-)
-
-func TestDialector(t *testing.T) {
- // This is the DSN of the in-memory SQLite database for these tests.
- const InMemoryDSN = "file:testdatabase?mode=memory&cache=shared"
- // This is the custom SQLite driver name.
- const CustomDriverName = "my_custom_driver"
-
- // Register the custom SQlite3 driver.
- // It will have one custom function called "my_custom_function".
-
- sql.Register(CustomDriverName,
- &sqlite.Driver{},
- )
-
- rows := []struct {
- description string
- dialector *Dialector
- openSuccess bool
- query string
- querySuccess bool
- }{
- {
- description: "Default driver",
- dialector: &Dialector{
- DSN: InMemoryDSN,
- },
- openSuccess: true,
- query: "SELECT 1",
- querySuccess: true,
- },
- {
- description: "Explicit default driver",
- dialector: &Dialector{
- DriverName: DriverName,
- DSN: InMemoryDSN,
- },
- openSuccess: true,
- query: "SELECT 1",
- querySuccess: true,
- },
- {
- description: "Bad driver",
- dialector: &Dialector{
- DriverName: "not-a-real-driver",
- DSN: InMemoryDSN,
- },
- openSuccess: false,
- },
- // {
- // description: "Explicit default driver, custom function",
- // dialector: &Dialector{
- // DriverName: DriverName,
- // DSN: InMemoryDSN,
- // },
- // openSuccess: true,
- // query: "SELECT my_custom_function()",
- // querySuccess: false,
- // },
- {
- description: "Custom driver",
- dialector: &Dialector{
- DriverName: CustomDriverName,
- DSN: InMemoryDSN,
- },
- openSuccess: true,
- query: "SELECT 1",
- querySuccess: true,
- },
- // {
- // description: "Custom driver, custom function",
- // dialector: &Dialector{
- // DriverName: CustomDriverName,
- // DSN: InMemoryDSN,
- // },
- // openSuccess: true,
- // query: "SELECT my_custom_function()",
- // querySuccess: true,
- // },
- }
- for rowIndex, row := range rows {
- t.Run(fmt.Sprintf("%d/%s", rowIndex, row.description), func(t *testing.T) {
- db, err := gorm.Open(row.dialector, &gorm.Config{})
- if !row.openSuccess {
- if err == nil {
- t.Errorf("Expected Open to fail.")
- }
- return
- }
-
- if err != nil {
- t.Errorf("Expected Open to succeed; got error: %v", err)
- }
- if db == nil {
- t.Errorf("Expected db to be non-nil.")
- }
- if row.query != "" {
- err = db.Exec(row.query).Error
- if !row.querySuccess {
- if err == nil {
- t.Errorf("Expected query to fail.")
- }
- return
- }
-
- if err != nil {
- t.Errorf("Expected query to succeed; got error: %v", err)
- }
- }
- })
- }
-}
diff --git a/internal/db/wasmsqlite/sqlite_version_test.go b/internal/db/wasmsqlite/sqlite_version_test.go
deleted file mode 100644
index 7d8c472..0000000
--- a/internal/db/wasmsqlite/sqlite_version_test.go
+++ /dev/null
@@ -1,23 +0,0 @@
-package wasmsqlite
-
-import (
- "database/sql"
- "log"
- "testing"
-)
-
-func TestSQLiteVersion(t *testing.T) {
- var version string
-
- db, err := sql.Open(DriverName, ":memory:")
- if err != nil {
- log.Fatal(err)
- }
-
- row := db.QueryRow("select sqlite_version()")
- if row.Scan(&version) != nil {
- log.Fatal(err)
- }
-
- t.Log(version)
-}
diff --git a/server/db.go b/server/db.go
index 2f55aa2..5f764f8 100644
--- a/server/db.go
+++ b/server/db.go
@@ -177,6 +177,12 @@ func (ts *Server) initDatabase() (err error) {
return
}
+ // Apply an out-of-band encryption key (never persisted to the config
+ // file) so the on-disk SQLite database is encrypted at rest.
+ if ts.opts.dbKey != "" {
+ ts.opts.dbConfig.EncryptionKey = ts.opts.dbKey
+ }
+
ts.db, err = db.NewClient(ts.opts.dbConfig, dbLogger)
if err != nil {
return
diff --git a/server/encryption_test.go b/server/encryption_test.go
new file mode 100644
index 0000000..9291fd8
--- /dev/null
+++ b/server/encryption_test.go
@@ -0,0 +1,85 @@
+//go:build !cgo_sqlite
+
+package server
+
+/*
+ team - Embedded teamserver for Go programs and CLI applications
+ Copyright (C) 2023 Reeflective
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see .
+*/
+
+import (
+ "bytes"
+ "io"
+ "log/slog"
+ "os"
+ "testing"
+)
+
+// TestDatabaseEncryptionAtRest exercises the public WithDatabaseKey option end
+// to end: a file-based teamserver created with a key must produce an on-disk
+// database that is encrypted (no SQLite header, no user data in the clear). The
+// adiantum VFS used here is pure-Go, so this test excludes the cgo_sqlite build.
+func TestDatabaseEncryptionAtRest(t *testing.T) {
+ const userMarker = "ENCRYPTED_USER_MARKER"
+
+ home := t.TempDir()
+
+ // Inject a discard log handler so the server opens no log file. That keeps
+ // the database on disk (unlike WithInMemory) while avoiding a lingering open
+ // log-file handle, which on Windows would block the t.TempDir() cleanup ("the
+ // process cannot access the file because it is being used by another
+ // process"). The SQLite database file itself is deletable while open.
+ discard := slog.NewTextHandler(io.Discard, nil)
+ ts, err := New("enctest",
+ WithHomeDirectory(home),
+ WithLogger(discard),
+ WithDatabaseKey("correct horse battery staple"),
+ )
+ if err != nil {
+ t.Fatalf("server.New: %v", err)
+ }
+ if err := ts.init(); err != nil {
+ t.Fatalf("server.init: %v", err)
+ }
+
+ // Write some recognizable data into the database.
+ if _, err := ts.UserCreate(userMarker, "localhost", 31337); err != nil {
+ t.Fatalf("UserCreate: %v", err)
+ }
+
+ // Resolve the on-disk database path and flush the connection.
+ dbPath := ts.opts.dbConfig.Database
+ if dbPath == "" {
+ t.Fatal("expected a file-based database path")
+ }
+ if sqlDB, derr := ts.db.DB(); derr == nil {
+ sqlDB.Close()
+ }
+
+ raw, err := os.ReadFile(dbPath)
+ if err != nil {
+ t.Fatalf("read database file %q: %v", dbPath, err)
+ }
+ if len(raw) == 0 {
+ t.Fatal("database file is empty")
+ }
+ if bytes.HasPrefix(raw, []byte("SQLite format 3")) {
+ t.Fatal("database is not encrypted: standard SQLite header present")
+ }
+ if bytes.Contains(raw, []byte(userMarker)) {
+ t.Fatal("database is not encrypted: user name found in cleartext on disk")
+ }
+}
diff --git a/server/options.go b/server/options.go
index 87bbf45..9c64820 100644
--- a/server/options.go
+++ b/server/options.go
@@ -53,6 +53,7 @@ type opts struct {
config *Config
dbConfig *db.Config
+ dbKey string
db *gorm.DB
logger slog.Handler
consoleStyle func(*log.ConsoleOptions)
@@ -165,6 +166,26 @@ func WithDatabaseConfig(config *db.Config) Options {
}
}
+// WithDatabaseKey enables transparent encryption-at-rest for the default,
+// file-based SQLite database. The provided key is used to derive the encryption
+// key (via Argon2id) for the pure-Go adiantum VFS, so it works out of the box on
+// the default and wasm_sqlite builds, with no CGO and no external tooling.
+//
+// The key is never written to disk (in particular, it is not stored in the
+// database configuration file): the application is responsible for sourcing it
+// securely (environment variable, prompt, KMS, ...) on each start.
+//
+// This option has no effect on in-memory databases (nothing is persisted), on
+// user-provided backends passed with WithDatabase, or on the cgo_sqlite build
+// (whose SQLite engine does not provide the adiantum VFS).
+//
+// This option can only be used once, and must be passed to server.New().
+func WithDatabaseKey(key string) Options {
+ return func(opts *opts) {
+ opts.dbKey = key
+ }
+}
+
// WithHomeDirectory sets the default path (~/.app/) of the application directory.
// This path can still be overridden at the user-level with the env var APP_ROOT_DIR.
//
diff --git a/server/users_test.go b/server/users_test.go
new file mode 100644
index 0000000..fc9e520
--- /dev/null
+++ b/server/users_test.go
@@ -0,0 +1,189 @@
+package server
+
+/*
+ team - Embedded teamserver for Go programs and CLI applications
+ Copyright (C) 2023 Reeflective
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see .
+*/
+
+import (
+ "crypto/tls"
+ "errors"
+ "testing"
+)
+
+// newTestServer returns a fully-initialized in-memory teamserver. Calling init()
+// bootstraps the database and certificate infrastructure without needing a
+// transport handler, which is all the user-management primitives require.
+func newTestServer(t *testing.T) *Server {
+ t.Helper()
+
+ ts, err := New("usertest", WithInMemory())
+ if err != nil {
+ t.Fatalf("server.New: %v", err)
+ }
+
+ if err := ts.init(); err != nil {
+ t.Fatalf("server.init: %v", err)
+ }
+
+ return ts
+}
+
+// TestUserCreateValidation pins the input validation on UserCreate: user names
+// are restricted to alphanumerics (plus - and _), and neither the name nor the
+// host may be empty. All rejections surface as ErrUserConfig.
+func TestUserCreateValidation(t *testing.T) {
+ ts := newTestServer(t)
+
+ cases := []struct {
+ name string
+ user string
+ lhost string
+ }{
+ {"empty name", "", "localhost"},
+ {"empty host", "alice", ""},
+ {"space in name", "alice bob", "localhost"},
+ {"slash in name", "alice/bob", "localhost"},
+ {"dot in name", "alice.bob", "localhost"},
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ cfg, err := ts.UserCreate(tc.user, tc.lhost, 31337)
+ if err == nil {
+ t.Fatalf("UserCreate(%q, %q) should have failed", tc.user, tc.lhost)
+ }
+ if !errors.Is(err, ErrUserConfig) {
+ t.Fatalf("expected ErrUserConfig, got %v", err)
+ }
+ if cfg != nil {
+ t.Fatal("expected nil config on validation failure")
+ }
+ })
+ }
+}
+
+// TestUserCreateTokenUniqueness ensures two users minted back-to-back receive
+// distinct API tokens and distinct client certificates, and that each token
+// authenticates back to the right identity.
+func TestUserCreateTokenUniqueness(t *testing.T) {
+ ts := newTestServer(t)
+
+ alice, err := ts.UserCreate("alice", "localhost", 31337)
+ if err != nil {
+ t.Fatalf("UserCreate(alice): %v", err)
+ }
+ bob, err := ts.UserCreate("bob", "localhost", 31337)
+ if err != nil {
+ t.Fatalf("UserCreate(bob): %v", err)
+ }
+
+ if alice.Token == "" || bob.Token == "" {
+ t.Fatal("tokens must be non-empty")
+ }
+ if alice.Token == bob.Token {
+ t.Fatal("two users must not share the same API token")
+ }
+ if alice.Certificate == bob.Certificate {
+ t.Fatal("two users must not share the same client certificate")
+ }
+
+ if u, err := ts.Authenticate(alice.Token); err != nil || u == nil || u.Name != "alice" {
+ t.Fatalf("alice token must authenticate as alice, got user=%v err=%v", u, err)
+ }
+ if u, err := ts.Authenticate(bob.Token); err != nil || u == nil || u.Name != "bob" {
+ t.Fatalf("bob token must authenticate as bob, got user=%v err=%v", u, err)
+ }
+}
+
+// TestUserDeleteRevokesAuth is the security-critical guarantee documented on
+// UserDelete: once a user is deleted, its token no longer authenticates (even
+// though it was previously cached) and its client certificate is gone.
+func TestUserDeleteRevokesAuth(t *testing.T) {
+ ts := newTestServer(t)
+
+ cfg, err := ts.UserCreate("mallory", "localhost", 31337)
+ if err != nil {
+ t.Fatalf("UserCreate: %v", err)
+ }
+
+ // Authenticate once so the token is now in the in-memory cache; deletion
+ // must invalidate the cache too, not just the database row.
+ if _, err := ts.Authenticate(cfg.Token); err != nil {
+ t.Fatalf("Authenticate before delete: %v", err)
+ }
+
+ // The client certificate must exist before deletion.
+ if _, _, err := ts.certs.UserClientGetCertificate("mallory"); err != nil {
+ t.Fatalf("client certificate should exist before delete: %v", err)
+ }
+
+ if err := ts.UserDelete("mallory"); err != nil {
+ t.Fatalf("UserDelete: %v", err)
+ }
+
+ // Token must no longer authenticate.
+ user, err := ts.Authenticate(cfg.Token)
+ if err == nil || user != nil {
+ t.Fatalf("deleted user's token must be rejected, got user=%v err=%v", user, err)
+ }
+ if !errors.Is(err, ErrUnauthenticated) {
+ t.Fatalf("expected ErrUnauthenticated after delete, got %v", err)
+ }
+
+ // The client certificate must be gone.
+ if _, _, err := ts.certs.UserClientGetCertificate("mallory"); err == nil {
+ t.Fatal("client certificate should have been removed on delete")
+ }
+}
+
+// TestUsersTLSConfig verifies the server-side mutual-TLS configuration is locked
+// down: it requires and verifies client certificates, pins TLS 1.3, and carries
+// exactly one server certificate plus a client CA pool.
+func TestUsersTLSConfig(t *testing.T) {
+ ts := newTestServer(t)
+
+ tlsConfig, err := ts.UsersTLSConfig()
+ if err != nil {
+ t.Fatalf("UsersTLSConfig: %v", err)
+ }
+
+ if tlsConfig.ClientAuth != tls.RequireAndVerifyClientCert {
+ t.Fatalf("expected RequireAndVerifyClientCert, got %v", tlsConfig.ClientAuth)
+ }
+ if tlsConfig.MinVersion != tls.VersionTLS13 {
+ t.Fatalf("expected MinVersion TLS 1.3, got %x", tlsConfig.MinVersion)
+ }
+ if len(tlsConfig.Certificates) != 1 {
+ t.Fatalf("expected exactly 1 server certificate, got %d", len(tlsConfig.Certificates))
+ }
+ if tlsConfig.ClientCAs == nil || tlsConfig.RootCAs == nil {
+ t.Fatal("expected both a client CA pool and a root CA pool to be set")
+ }
+}
+
+// TestAuthenticateRejectsGarbage ensures a well-formed-but-unknown token and an
+// empty token are both rejected without leaking an identity.
+func TestAuthenticateRejectsGarbage(t *testing.T) {
+ ts := newTestServer(t)
+
+ for _, tok := range []string{"", "deadbeef", "not-a-real-token"} {
+ user, err := ts.Authenticate(tok)
+ if err == nil || user != nil {
+ t.Fatalf("token %q must be rejected, got user=%v err=%v", tok, user, err)
+ }
+ }
+}