mirror of
https://github.com/pocketbase/pocketbase.git
synced 2026-09-20 13:30:50 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dcaacd0d28 | ||
|
|
21d7cff43d | ||
|
|
2aec49a1f1 | ||
|
|
17b7ee5200 | ||
|
|
2b5f00b62e | ||
|
|
8b93aac8e3 | ||
|
|
eb51cdf1aa | ||
|
|
647997517f | ||
|
|
0e1f6b69d0 | ||
|
|
6718f4469b | ||
|
|
c27349905c | ||
|
|
1b48bdb81a | ||
|
|
bb801e7de0 | ||
|
|
adf902cae7 | ||
|
|
d2a617848d | ||
|
|
7ae2a7e846 | ||
|
|
448120bf18 | ||
|
|
667bcac680 | ||
|
|
64fd347628 | ||
|
|
0340d8add8 | ||
|
|
2c9e8995f9 | ||
|
|
f01f1df07a | ||
|
|
f4533f3d26 | ||
|
|
7a47a8a979 | ||
|
|
deccb3dbdb | ||
|
|
c51148e4d7 | ||
|
|
51ee1b5367 | ||
|
|
b8d7609e9e | ||
|
|
32af49dbec | ||
|
|
cf9e2a33bb | ||
|
|
a27298d1ef | ||
|
|
536707bfe7 | ||
|
|
2128b15541 | ||
|
|
eb1246fc41 | ||
|
|
ae371e8481 | ||
|
|
39df263a03 | ||
|
|
b3fa1f0fea | ||
|
|
ecfae2e5c9 | ||
|
|
e5477961ad | ||
|
|
2d40487b21 | ||
|
|
8564a69a94 | ||
|
|
3f58908734 | ||
|
|
4c010847e3 | ||
|
|
4c73e16f54 | ||
|
|
2a34eca07a | ||
|
|
a74d227418 | ||
|
|
7001a22d92 | ||
|
|
a7e3f08df0 | ||
|
|
15583ba718 | ||
|
|
e25c252fc2 | ||
|
|
a15b192a42 | ||
|
|
2a4b3315c6 | ||
|
|
6d08a5f36f | ||
|
|
fd97732d4d | ||
|
|
36ab3fd162 | ||
|
|
8f6f87902a | ||
|
|
55c6bed57f | ||
|
|
ba7c8e2108 | ||
|
|
c1921aeef8 | ||
|
|
012546e838 | ||
|
|
f792a9e08d | ||
|
|
5fb1e85372 | ||
|
|
a5ceee33df | ||
|
|
59e4939e1d | ||
|
|
1f46b30895 | ||
|
|
a8b2f0f6f1 | ||
|
|
d37bf6452c | ||
|
|
c26ac2d53f | ||
|
|
e1c751a7e7 | ||
|
|
f7d4722052 | ||
|
|
7459c9208f | ||
|
|
c1ff1c6155 | ||
|
|
3dc1bf6fa7 | ||
|
|
d3cc87abee | ||
|
|
19ad827302 | ||
|
|
40830b6c43 | ||
|
|
f234132629 | ||
|
|
9b880f5ab4 | ||
|
|
d5775ff657 | ||
|
|
41ba7e12e1 | ||
|
|
5ef6b3a8be | ||
|
|
c673d9d314 | ||
|
|
a7aa3da67e | ||
|
|
0439af458f | ||
|
|
6f3241399c | ||
|
|
19ccc70fe5 | ||
|
|
9033cd109e | ||
|
|
e1773eead0 | ||
|
|
f6fff85d07 | ||
|
|
166d2eafc7 | ||
|
|
d710446c71 | ||
|
|
8f6cb5ad2d | ||
|
|
775417ac2b | ||
|
|
079616ee8e | ||
|
|
26b794aa08 | ||
|
|
d55610511d | ||
|
|
a00606d969 | ||
|
|
4abc8ae021 | ||
|
|
f91f009fce | ||
|
|
7fc1d979dd | ||
|
|
ede7804a80 | ||
|
|
cea287a2c1 | ||
|
|
9e03ac4dc4 | ||
|
|
e713de1e44 | ||
|
|
233ab62f8e | ||
|
|
3449084e54 | ||
|
|
15c05b9679 | ||
|
|
fb57c8091d | ||
|
|
ca528cef03 | ||
|
|
4ceab4e7ed | ||
|
|
e8fa51526a | ||
|
|
a43713ce14 | ||
|
|
7d7d20744e | ||
|
|
bd16680548 | ||
|
|
84ba89d5af | ||
|
|
bf5b1db672 | ||
|
|
71d3f8f4c7 | ||
|
|
687a79b450 | ||
|
|
738f71f244 | ||
|
|
64f3c5a604 | ||
|
|
a8c996c93d | ||
|
|
9ae8536515 | ||
|
|
5f6b7f6cc0 | ||
|
|
89de29fc84 | ||
|
|
c25e67e13d | ||
|
|
8e582acbee | ||
|
|
b9e257d2b1 | ||
|
|
e964b019c2 | ||
|
|
4cbb7f58cd | ||
|
|
8815f6060c | ||
|
|
5183280c39 | ||
|
|
1f45b858a6 | ||
|
|
6d46cefd1f | ||
|
|
b1a63bb98d | ||
|
|
1248421703 | ||
|
|
be3dd42eac | ||
|
|
0eeae9de80 | ||
|
|
55b439cb1c | ||
|
|
0696a252cc | ||
|
|
54c52f696c | ||
|
|
21f442293f | ||
|
|
18d6a1c529 | ||
|
|
ad321c01e0 | ||
|
|
29c99319dc | ||
|
|
18285e7505 | ||
|
|
846b56d393 | ||
|
|
f30c9f263f | ||
|
|
b63268559f | ||
|
|
5c899a4cf0 | ||
|
|
506bfca8b2 | ||
|
|
007fcd36b8 | ||
|
|
707f35f461 | ||
|
|
972b06c708 | ||
|
|
37bac5cc50 | ||
|
|
aa6eaa7319 | ||
|
|
68a9782c03 | ||
|
|
869d1cbcf7 | ||
|
|
451611776e | ||
|
|
9c7f48a66e | ||
|
|
59b41c8202 | ||
|
|
94658712c6 | ||
|
|
2c4ac070a3 | ||
|
|
e206e303ca | ||
|
|
e60f470188 | ||
|
|
9cf5e28700 | ||
|
|
7aefcd9bf6 | ||
|
|
693954cdcd | ||
|
|
355f7053fd | ||
|
|
808f5054d0 | ||
|
|
f1d546c845 | ||
|
|
dba66d4da1 | ||
|
|
4070a11660 | ||
|
|
45b72dd6b3 | ||
|
|
599c542c5a | ||
|
|
b8cd686b32 | ||
|
|
604009bd10 | ||
|
|
02f72638b8 | ||
|
|
23fbfab63a | ||
|
|
98cc8e2aee | ||
|
|
04018f931b | ||
|
|
d2028143df | ||
|
|
6400924d29 | ||
|
|
44a69eb4ba | ||
|
|
0fa5edb0b1 | ||
|
|
799e1d96f8 | ||
|
|
9ba710cdc5 | ||
|
|
a4953cbb4e | ||
|
|
647158f62d | ||
|
|
2deca759fa | ||
|
|
bd65125744 | ||
|
|
328b99a690 | ||
|
|
33539452de | ||
|
|
c6f03cda43 | ||
|
|
2d3531dd66 | ||
|
|
7ac3a74440 | ||
|
|
3bce173748 | ||
|
|
b024737ec8 | ||
|
|
675d459137 | ||
|
|
8c9b657132 | ||
|
|
d8963c6fc3 | ||
|
|
3e1a19685b | ||
|
|
c4a660d2d2 | ||
|
|
c12c873a65 | ||
|
|
550260b381 | ||
|
|
b9922e4843 | ||
|
|
3c3a61c457 | ||
|
|
aed8367231 | ||
|
|
341bcc4a0e | ||
|
|
3b9a9df171 | ||
|
|
a230cc1719 | ||
|
|
7dee9d5cc4 | ||
|
|
0b54d1736e | ||
|
|
39408f135b | ||
|
|
6e9cf986c5 | ||
|
|
4528f075dc | ||
|
|
f3566149b8 | ||
|
|
9322b13d15 | ||
|
|
77d295730e | ||
|
|
a998618d75 | ||
|
|
4c096fd745 | ||
|
|
c95e50c8a5 | ||
|
|
bac5d76725 | ||
|
|
50fce1f3cf | ||
|
|
521df149a2 | ||
|
|
39dc1d2795 | ||
|
|
3d14addfef | ||
|
|
bc519231d9 | ||
|
|
b1c7a012c5 | ||
|
|
01814067b1 | ||
|
|
7225b380d5 | ||
|
|
fa791b7e96 | ||
|
|
0ff5606d80 | ||
|
|
4cddb6b5cb | ||
|
|
46dc6cc47c | ||
|
|
65693d1916 | ||
|
|
a2abeb872a | ||
|
|
6115fb3874 | ||
|
|
cb6ffc1e7b | ||
|
|
152f6a9d1f | ||
|
|
fe656a1c42 | ||
|
|
7e7e2e98a4 | ||
|
|
099230a552 | ||
|
|
5e0718176d | ||
|
|
1a28532546 | ||
|
|
8bb03d2e6b | ||
|
|
639522b142 | ||
|
|
9cef6ebd82 | ||
|
|
5298543ce4 | ||
|
|
bcb9c22685 | ||
|
|
90dba45d7c | ||
|
|
9cbb2e750e | ||
|
|
6385c5e646 | ||
|
|
32393990bb | ||
|
|
838ed661ce | ||
|
|
d84e57174b | ||
|
|
a6cafd1ed7 | ||
|
|
b0db2399b8 | ||
|
|
81d0af6e80 | ||
|
|
5f5f0ed793 | ||
|
|
353248c34a | ||
|
|
93d48a85ac | ||
|
|
b84930f21a | ||
|
|
3cbab96f51 | ||
|
|
6c005c4a9a | ||
|
|
ccc3707fb6 | ||
|
|
3d36ff7e96 | ||
|
|
0b2eb24c6f | ||
|
|
954067860c | ||
|
|
9a8c9dd115 | ||
|
|
a1ad5004f8 | ||
|
|
7006e1f5d7 | ||
|
|
8be8f3f3cb | ||
|
|
00fd007d50 | ||
|
|
96bfc69c8e | ||
|
|
e542b0d8c5 | ||
|
|
9814dda8e4 | ||
|
|
978fdd3ce7 | ||
|
|
9cf89870e7 | ||
|
|
b8c54568e3 | ||
|
|
843bbf99cc | ||
|
|
daffb0f86e | ||
|
|
1f5c3328e5 | ||
|
|
ed5f3b86f5 | ||
|
|
1ba2d14231 | ||
|
|
6cda610ede | ||
|
|
2fa5233fa6 | ||
|
|
030dfc2690 | ||
|
|
8c11e2ef01 | ||
|
|
4b64e0910b | ||
|
|
4a6bc453de | ||
|
|
96d09a30c4 | ||
|
|
a0d7f23d77 | ||
|
|
74108d84ca | ||
|
|
4bc28abac4 | ||
|
|
b79a7982bf | ||
|
|
b717896232 | ||
|
|
9d53aec9ee | ||
|
|
9d30ca81cb | ||
|
|
93b3788448 | ||
|
|
0ac4c9e1fc | ||
|
|
df1a947b61 | ||
|
|
06a7f1af05 | ||
|
|
07ac5bf6a2 | ||
|
|
f56c52a1f7 | ||
|
|
f0b57c6b91 | ||
|
|
f61d0ec6f7 | ||
|
|
4e78f1e4ad | ||
|
|
235670fae8 | ||
|
|
f5ff7193a9 | ||
|
|
9fe94f5c7d | ||
|
|
0f9ddbf7ec | ||
|
|
f14105b04a | ||
|
|
3bfa0b6dd4 | ||
|
|
9e3c59f966 | ||
|
|
a908d20dcd | ||
|
|
49b084cf50 | ||
|
|
0b8c7f6883 | ||
|
|
587cfc335c | ||
|
|
3f4f4cf031 | ||
|
|
d4f160d959 | ||
|
|
07cd758112 | ||
|
|
beb8e7924d | ||
|
|
72fdf0d116 | ||
|
|
7e14ea7cfb | ||
|
|
25c4db7a30 | ||
|
|
e86b1f54a0 | ||
|
|
7be389704d | ||
|
|
cfaff31d97 | ||
|
|
9bf66be870 | ||
|
|
efda3d5a0b | ||
|
|
a516435f2e | ||
|
|
ccd010c490 | ||
|
|
456ced75ce | ||
|
|
b7d32c23aa | ||
|
|
04ddae46c9 | ||
|
|
7d10d20de1 | ||
|
|
1de56d3d9e | ||
|
|
80f710731c |
@@ -2,7 +2,7 @@
|
||||
|
||||
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
|
||||
patreon: # Replace with a single Patreon username
|
||||
open_collective: # Replace with a single Open Collective username
|
||||
open_collective: pocketbase
|
||||
ko_fi: # Replace with a single Ko-fi username
|
||||
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
|
||||
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# Security
|
||||
|
||||
If you discover a security vulnerability within PocketBase, please send an e-mail to **support at pocketbase.io**.
|
||||
|
||||
All reports will be promptly addressed, and you'll be credited accordingly.
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: '>=1.19.0'
|
||||
go-version: '>=1.20.0'
|
||||
|
||||
# This step usually is not needed because the /ui/dist is pregenerated locally
|
||||
# but its here to ensure that each release embeds the latest admin ui artifacts.
|
||||
|
||||
+5
-4
@@ -6,12 +6,13 @@
|
||||
# goreleaser builds folder
|
||||
/.builds/
|
||||
|
||||
# examples app directories
|
||||
pb_data
|
||||
pb_public
|
||||
|
||||
# tests coverage
|
||||
coverage.out
|
||||
|
||||
# plaintask todo files
|
||||
*.todo
|
||||
|
||||
# generated markdown previews
|
||||
README.html
|
||||
CHANGELOG.html
|
||||
LICENSE.html
|
||||
|
||||
+1136
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -26,7 +26,7 @@ git clone https://github.com/your_username/pocketbase.git
|
||||
|
||||
## Making changes in the Go code
|
||||
|
||||
PocketBase is a Go package, which means that in order to run the project you'll have to create a Go `main` program that imports the package.
|
||||
PocketBase is distributed as a Go package, which means that in order to run the project you'll have to create a Go `main` program that imports the package.
|
||||
|
||||
The repository already includes such program, located in `/examples/base`, that is also used for the prebuilt executables.
|
||||
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
The MIT License (MIT)
|
||||
Copyright (c) 2022, Gani Georgiev
|
||||
Copyright (c) 2022 - present, Gani Georgiev
|
||||
|
||||
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,
|
||||
|
||||
@@ -2,8 +2,8 @@ lint:
|
||||
golangci-lint run -c ./golangci.yml ./...
|
||||
|
||||
test:
|
||||
go test -v --cover ./...
|
||||
go test ./... -v --cover
|
||||
|
||||
test-report:
|
||||
go test -v --cover -coverprofile=coverage.out ./...
|
||||
go test ./... -v --cover -coverprofile=coverage.out
|
||||
go tool cover -html=coverage.out
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<p align="center">
|
||||
<a href="https://pocketbase.io" target="_blank" rel="noopener">
|
||||
<img src="https://i.imgur.com/ZfD4BHO.png" alt="PocketBase - open source backend in 1 file" />
|
||||
<img src="https://i.imgur.com/5qimnm5.png" alt="PocketBase - open source backend in 1 file" />
|
||||
</a>
|
||||
</p>
|
||||
|
||||
@@ -19,8 +19,7 @@
|
||||
|
||||
**For documentation and examples, please visit https://pocketbase.io/docs.**
|
||||
|
||||
> ⚠️ Although the web API definitions are considered stable,
|
||||
> please keep in mind that PocketBase is still under active development
|
||||
> ⚠️ Please keep in mind that PocketBase is still under active development
|
||||
> and therefore full backward compatibility is not guaranteed before reaching v1.0.0.
|
||||
|
||||
|
||||
@@ -34,7 +33,7 @@ The easiest way to interact with the API is to use one of the official SDK clien
|
||||
|
||||
## Overview
|
||||
|
||||
PocketBase could be used as a standalone app or as a Go framework/toolkit that enables you to build
|
||||
PocketBase could be [downloaded directly as a standalone app](https://github.com/pocketbase/pocketbase/releases) or it could be used as a Go framework/toolkit which allows you to build
|
||||
your own custom app specific business logic and still have a single portable executable at the end.
|
||||
|
||||
### Installation
|
||||
@@ -64,15 +63,15 @@ func main() {
|
||||
app := pocketbase.New()
|
||||
|
||||
app.OnBeforeServe().Add(func(e *core.ServeEvent) error {
|
||||
// add new "GET /api/hello" route to the app router (echo)
|
||||
// add new "GET /hello" route to the app router (echo)
|
||||
e.Router.AddRoute(echo.Route{
|
||||
Method: http.MethodGet,
|
||||
Path: "/api/hello",
|
||||
Path: "/hello",
|
||||
Handler: func(c echo.Context) error {
|
||||
return c.String(200, "Hello world!")
|
||||
},
|
||||
Middlewares: []echo.MiddlewareFunc{
|
||||
apis.RequireAdminOrUserAuth(),
|
||||
apis.ActivityLogger(app),
|
||||
},
|
||||
})
|
||||
|
||||
@@ -91,11 +90,33 @@ Running/building the application is the same as for any other Go program, aka. j
|
||||
|
||||
**PocketBase embeds SQLite, but doesn't require CGO.**
|
||||
|
||||
If CGO is enabled, it will use [mattn/go-sqlite3](https://pkg.go.dev/github.com/mattn/go-sqlite3) driver, otherwise - [modernc.org/sqlite](https://pkg.go.dev/modernc.org/sqlite). You can enable and disable CGO by setting the `CGO_ENABLED` environment variable to `1` or `0` respectively.
|
||||
|
||||
If CGO is enabled (aka. `CGO_ENABLED=1`), it will use [mattn/go-sqlite3](https://pkg.go.dev/github.com/mattn/go-sqlite3) driver, otherwise - [modernc.org/sqlite](https://pkg.go.dev/modernc.org/sqlite).
|
||||
Enable CGO only if you really need to squeeze the read/write query performance at the expense of complicating cross compilation.
|
||||
|
||||
To build the minimal standalone executable, like the prebuilt ones in the releases page, you can simply run `go build` inside the `examples/base` directory.
|
||||
To build the minimal standalone executable, like the prebuilt ones in the releases page, you can simply run `go build` inside the `examples/base` directory:
|
||||
|
||||
0. [Install Go 1.18+](https://go.dev/doc/install) (_if you haven't already_)
|
||||
1. Clone/download the repo
|
||||
2. Navigate to `examples/base`
|
||||
3. Run `GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build`
|
||||
(_https://go.dev/doc/install/source#environment_)
|
||||
4. Start the created executable by running `./base serve`.
|
||||
|
||||
The supported build targets by the non-cgo driver at the moment are:
|
||||
```
|
||||
darwin amd64
|
||||
darwin arm64
|
||||
freebsd amd64
|
||||
freebsd arm64
|
||||
linux 386
|
||||
linux amd64
|
||||
linux arm
|
||||
linux arm64
|
||||
linux ppc64le
|
||||
linux riscv64
|
||||
windows amd64
|
||||
windows arm64
|
||||
```
|
||||
|
||||
### Testing
|
||||
|
||||
@@ -111,12 +132,13 @@ Check also the [Testing guide](http://pocketbase.io/docs/testing) to learn how t
|
||||
|
||||
If you discover a security vulnerability within PocketBase, please send an e-mail to **support at pocketbase.io**.
|
||||
|
||||
All reports will be promptly addressed and you'll be credited accordingly.
|
||||
All reports will be promptly addressed, and you'll be credited accordingly.
|
||||
|
||||
|
||||
## Contributing
|
||||
|
||||
PocketBase is free and open source project licensed under the [MIT License](LICENSE.md).
|
||||
You are free to do whatever you want with it, even offering it as a paid service.
|
||||
|
||||
You could help continuing its development by:
|
||||
|
||||
@@ -124,5 +146,14 @@ You could help continuing its development by:
|
||||
- [Suggest new features and report issues](https://github.com/pocketbase/pocketbase/issues)
|
||||
- [Donate a small amount](https://pocketbase.io/support-us)
|
||||
|
||||
> Please note that PocketBase was initially created to serve as a new backend for my other open source project - [Presentator](https://presentator.io) (see [#183](https://github.com/presentator/presentator/issues/183)),
|
||||
so all feature requests will be first aligned with what we need for Presentator v3.
|
||||
PRs for _small features_ (eg. adding new OAuth2 providers), bug and documentation fixes, etc. are more than welcome.
|
||||
|
||||
But please refrain creating PRs for _big features_ without previously discussing the implementation details. Reviewing big PRs often requires a lot of time and tedious back-and-forth communication.
|
||||
PocketBase has a [roadmap](https://github.com/orgs/pocketbase/projects/2)
|
||||
and I try to work on issues in a specific order and such PRs often come in out of nowhere and skew all initial planning.
|
||||
|
||||
Don't get upset if I close your PR, even if it is well executed and tested. This doesn't mean that it will never be merged.
|
||||
Later we can always refer to it and/or take pieces of your implementation when the time comes to work on the issue (don't worry you'll be credited in the release notes).
|
||||
|
||||
_Please also note that PocketBase was initially created to serve as a new backend for my other open source project - [Presentator](https://presentator.io) (see [#183](https://github.com/presentator/presentator/issues/183)),
|
||||
so all feature requests will be first aligned with what we need for Presentator v3._
|
||||
|
||||
+157
-77
@@ -9,20 +9,19 @@ import (
|
||||
"github.com/pocketbase/pocketbase/forms"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/tokens"
|
||||
"github.com/pocketbase/pocketbase/tools/rest"
|
||||
"github.com/pocketbase/pocketbase/tools/routine"
|
||||
"github.com/pocketbase/pocketbase/tools/search"
|
||||
)
|
||||
|
||||
// BindAdminApi registers the admin api endpoints and the corresponding handlers.
|
||||
func BindAdminApi(app core.App, rg *echo.Group) {
|
||||
// bindAdminApi registers the admin api endpoints and the corresponding handlers.
|
||||
func bindAdminApi(app core.App, rg *echo.Group) {
|
||||
api := adminApi{app: app}
|
||||
|
||||
subGroup := rg.Group("/admins", ActivityLogger(app))
|
||||
subGroup.POST("/auth-via-email", api.emailAuth, RequireGuestOnly())
|
||||
subGroup.POST("/auth-with-password", api.authWithPassword)
|
||||
subGroup.POST("/request-password-reset", api.requestPasswordReset)
|
||||
subGroup.POST("/confirm-password-reset", api.confirmPasswordReset)
|
||||
subGroup.POST("/refresh", api.refresh, RequireAdminAuth())
|
||||
subGroup.POST("/auth-refresh", api.authRefresh, RequireAdminAuth())
|
||||
subGroup.GET("", api.list, RequireAdminAuth())
|
||||
subGroup.POST("", api.create, RequireAdminAuthOnlyIfAny(app))
|
||||
subGroup.GET("/:id", api.view, RequireAdminAuth())
|
||||
@@ -37,14 +36,13 @@ type adminApi struct {
|
||||
func (api *adminApi) authResponse(c echo.Context, admin *models.Admin) error {
|
||||
token, tokenErr := tokens.NewAdminAuthToken(api.app, admin)
|
||||
if tokenErr != nil {
|
||||
return rest.NewBadRequestError("Failed to create auth token.", tokenErr)
|
||||
return NewBadRequestError("Failed to create auth token.", tokenErr)
|
||||
}
|
||||
|
||||
event := &core.AdminAuthEvent{
|
||||
HttpContext: c,
|
||||
Admin: admin,
|
||||
Token: token,
|
||||
}
|
||||
event := new(core.AdminAuthEvent)
|
||||
event.HttpContext = c
|
||||
event.Admin = admin
|
||||
event.Token = token
|
||||
|
||||
return api.app.OnAdminAuthRequest().Trigger(event, func(e *core.AdminAuthEvent) error {
|
||||
return e.HttpContext.JSON(200, map[string]any{
|
||||
@@ -54,62 +52,139 @@ func (api *adminApi) authResponse(c echo.Context, admin *models.Admin) error {
|
||||
})
|
||||
}
|
||||
|
||||
func (api *adminApi) refresh(c echo.Context) error {
|
||||
func (api *adminApi) authRefresh(c echo.Context) error {
|
||||
admin, _ := c.Get(ContextAdminKey).(*models.Admin)
|
||||
if admin == nil {
|
||||
return rest.NewNotFoundError("Missing auth admin context.", nil)
|
||||
return NewNotFoundError("Missing auth admin context.", nil)
|
||||
}
|
||||
|
||||
return api.authResponse(c, admin)
|
||||
event := new(core.AdminAuthRefreshEvent)
|
||||
event.HttpContext = c
|
||||
event.Admin = admin
|
||||
|
||||
handlerErr := api.app.OnAdminBeforeAuthRefreshRequest().Trigger(event, func(e *core.AdminAuthRefreshEvent) error {
|
||||
return api.authResponse(e.HttpContext, e.Admin)
|
||||
})
|
||||
|
||||
if handlerErr == nil {
|
||||
if err := api.app.OnAdminAfterAuthRefreshRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return handlerErr
|
||||
}
|
||||
|
||||
func (api *adminApi) emailAuth(c echo.Context) error {
|
||||
func (api *adminApi) authWithPassword(c echo.Context) error {
|
||||
form := forms.NewAdminLogin(api.app)
|
||||
if readErr := c.Bind(form); readErr != nil {
|
||||
return rest.NewBadRequestError("An error occurred while reading the submitted data.", readErr)
|
||||
if err := c.Bind(form); err != nil {
|
||||
return NewBadRequestError("An error occurred while loading the submitted data.", err)
|
||||
}
|
||||
|
||||
admin, submitErr := form.Submit()
|
||||
if submitErr != nil {
|
||||
return rest.NewBadRequestError("Failed to authenticate.", submitErr)
|
||||
event := new(core.AdminAuthWithPasswordEvent)
|
||||
event.HttpContext = c
|
||||
event.Password = form.Password
|
||||
event.Identity = form.Identity
|
||||
|
||||
_, submitErr := form.Submit(func(next forms.InterceptorNextFunc[*models.Admin]) forms.InterceptorNextFunc[*models.Admin] {
|
||||
return func(admin *models.Admin) error {
|
||||
event.Admin = admin
|
||||
|
||||
return api.app.OnAdminBeforeAuthWithPasswordRequest().Trigger(event, func(e *core.AdminAuthWithPasswordEvent) error {
|
||||
if err := next(e.Admin); err != nil {
|
||||
return NewBadRequestError("Failed to authenticate.", err)
|
||||
}
|
||||
|
||||
return api.authResponse(e.HttpContext, e.Admin)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if submitErr == nil {
|
||||
if err := api.app.OnAdminAfterAuthWithPasswordRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return api.authResponse(c, admin)
|
||||
return submitErr
|
||||
}
|
||||
|
||||
func (api *adminApi) requestPasswordReset(c echo.Context) error {
|
||||
form := forms.NewAdminPasswordResetRequest(api.app)
|
||||
if err := c.Bind(form); err != nil {
|
||||
return rest.NewBadRequestError("An error occurred while reading the submitted data.", err)
|
||||
return NewBadRequestError("An error occurred while loading the submitted data.", err)
|
||||
}
|
||||
|
||||
if err := form.Validate(); err != nil {
|
||||
return rest.NewBadRequestError("An error occurred while validating the form.", err)
|
||||
return NewBadRequestError("An error occurred while validating the form.", err)
|
||||
}
|
||||
|
||||
// run in background because we don't need to show the result
|
||||
// (prevents admins enumeration)
|
||||
routine.FireAndForget(func() {
|
||||
if err := form.Submit(); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
event := new(core.AdminRequestPasswordResetEvent)
|
||||
event.HttpContext = c
|
||||
|
||||
submitErr := form.Submit(func(next forms.InterceptorNextFunc[*models.Admin]) forms.InterceptorNextFunc[*models.Admin] {
|
||||
return func(Admin *models.Admin) error {
|
||||
event.Admin = Admin
|
||||
|
||||
return api.app.OnAdminBeforeRequestPasswordResetRequest().Trigger(event, func(e *core.AdminRequestPasswordResetEvent) error {
|
||||
// run in background because we don't need to show the result to the client
|
||||
routine.FireAndForget(func() {
|
||||
if err := next(e.Admin); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
})
|
||||
|
||||
return e.HttpContext.NoContent(http.StatusNoContent)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return c.NoContent(http.StatusNoContent)
|
||||
if submitErr == nil {
|
||||
if err := api.app.OnAdminAfterRequestPasswordResetRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
} else if api.app.IsDebug() {
|
||||
log.Println(submitErr)
|
||||
}
|
||||
|
||||
// don't return the response error to prevent emails enumeration
|
||||
if !c.Response().Committed {
|
||||
c.NoContent(http.StatusNoContent)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (api *adminApi) confirmPasswordReset(c echo.Context) error {
|
||||
form := forms.NewAdminPasswordResetConfirm(api.app)
|
||||
if readErr := c.Bind(form); readErr != nil {
|
||||
return rest.NewBadRequestError("An error occurred while reading the submitted data.", readErr)
|
||||
return NewBadRequestError("An error occurred while loading the submitted data.", readErr)
|
||||
}
|
||||
|
||||
admin, submitErr := form.Submit()
|
||||
if submitErr != nil {
|
||||
return rest.NewBadRequestError("Failed to set new password.", submitErr)
|
||||
event := new(core.AdminConfirmPasswordResetEvent)
|
||||
event.HttpContext = c
|
||||
|
||||
_, submitErr := form.Submit(func(next forms.InterceptorNextFunc[*models.Admin]) forms.InterceptorNextFunc[*models.Admin] {
|
||||
return func(admin *models.Admin) error {
|
||||
event.Admin = admin
|
||||
|
||||
return api.app.OnAdminBeforeConfirmPasswordResetRequest().Trigger(event, func(e *core.AdminConfirmPasswordResetEvent) error {
|
||||
if err := next(e.Admin); err != nil {
|
||||
return NewBadRequestError("Failed to set new password.", err)
|
||||
}
|
||||
|
||||
return e.HttpContext.NoContent(http.StatusNoContent)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if submitErr == nil {
|
||||
if err := api.app.OnAdminAfterConfirmPasswordResetRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return api.authResponse(c, admin)
|
||||
return submitErr
|
||||
}
|
||||
|
||||
func (api *adminApi) list(c echo.Context) error {
|
||||
@@ -121,17 +196,16 @@ func (api *adminApi) list(c echo.Context) error {
|
||||
|
||||
result, err := search.NewProvider(fieldResolver).
|
||||
Query(api.app.Dao().AdminQuery()).
|
||||
ParseAndExec(c.QueryString(), &admins)
|
||||
ParseAndExec(c.QueryParams().Encode(), &admins)
|
||||
|
||||
if err != nil {
|
||||
return rest.NewBadRequestError("", err)
|
||||
return NewBadRequestError("", err)
|
||||
}
|
||||
|
||||
event := &core.AdminsListEvent{
|
||||
HttpContext: c,
|
||||
Admins: admins,
|
||||
Result: result,
|
||||
}
|
||||
event := new(core.AdminsListEvent)
|
||||
event.HttpContext = c
|
||||
event.Admins = admins
|
||||
event.Result = result
|
||||
|
||||
return api.app.OnAdminsListRequest().Trigger(event, func(e *core.AdminsListEvent) error {
|
||||
return e.HttpContext.JSON(http.StatusOK, e.Result)
|
||||
@@ -141,18 +215,17 @@ func (api *adminApi) list(c echo.Context) error {
|
||||
func (api *adminApi) view(c echo.Context) error {
|
||||
id := c.PathParam("id")
|
||||
if id == "" {
|
||||
return rest.NewNotFoundError("", nil)
|
||||
return NewNotFoundError("", nil)
|
||||
}
|
||||
|
||||
admin, err := api.app.Dao().FindAdminById(id)
|
||||
if err != nil || admin == nil {
|
||||
return rest.NewNotFoundError("", err)
|
||||
return NewNotFoundError("", err)
|
||||
}
|
||||
|
||||
event := &core.AdminViewEvent{
|
||||
HttpContext: c,
|
||||
Admin: admin,
|
||||
}
|
||||
event := new(core.AdminViewEvent)
|
||||
event.HttpContext = c
|
||||
event.Admin = admin
|
||||
|
||||
return api.app.OnAdminViewRequest().Trigger(event, func(e *core.AdminViewEvent) error {
|
||||
return e.HttpContext.JSON(http.StatusOK, e.Admin)
|
||||
@@ -166,20 +239,21 @@ func (api *adminApi) create(c echo.Context) error {
|
||||
|
||||
// load request
|
||||
if err := c.Bind(form); err != nil {
|
||||
return rest.NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
|
||||
return NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
|
||||
}
|
||||
|
||||
event := &core.AdminCreateEvent{
|
||||
HttpContext: c,
|
||||
Admin: admin,
|
||||
}
|
||||
event := new(core.AdminCreateEvent)
|
||||
event.HttpContext = c
|
||||
event.Admin = admin
|
||||
|
||||
// create the admin
|
||||
submitErr := form.Submit(func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
|
||||
return func() error {
|
||||
submitErr := form.Submit(func(next forms.InterceptorNextFunc[*models.Admin]) forms.InterceptorNextFunc[*models.Admin] {
|
||||
return func(m *models.Admin) error {
|
||||
event.Admin = m
|
||||
|
||||
return api.app.OnAdminBeforeCreateRequest().Trigger(event, func(e *core.AdminCreateEvent) error {
|
||||
if err := next(); err != nil {
|
||||
return rest.NewBadRequestError("Failed to create admin.", err)
|
||||
if err := next(e.Admin); err != nil {
|
||||
return NewBadRequestError("Failed to create admin.", err)
|
||||
}
|
||||
|
||||
return e.HttpContext.JSON(http.StatusOK, e.Admin)
|
||||
@@ -188,7 +262,9 @@ func (api *adminApi) create(c echo.Context) error {
|
||||
})
|
||||
|
||||
if submitErr == nil {
|
||||
api.app.OnAdminAfterCreateRequest().Trigger(event)
|
||||
if err := api.app.OnAdminAfterCreateRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return submitErr
|
||||
@@ -197,32 +273,33 @@ func (api *adminApi) create(c echo.Context) error {
|
||||
func (api *adminApi) update(c echo.Context) error {
|
||||
id := c.PathParam("id")
|
||||
if id == "" {
|
||||
return rest.NewNotFoundError("", nil)
|
||||
return NewNotFoundError("", nil)
|
||||
}
|
||||
|
||||
admin, err := api.app.Dao().FindAdminById(id)
|
||||
if err != nil || admin == nil {
|
||||
return rest.NewNotFoundError("", err)
|
||||
return NewNotFoundError("", err)
|
||||
}
|
||||
|
||||
form := forms.NewAdminUpsert(api.app, admin)
|
||||
|
||||
// load request
|
||||
if err := c.Bind(form); err != nil {
|
||||
return rest.NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
|
||||
return NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
|
||||
}
|
||||
|
||||
event := &core.AdminUpdateEvent{
|
||||
HttpContext: c,
|
||||
Admin: admin,
|
||||
}
|
||||
event := new(core.AdminUpdateEvent)
|
||||
event.HttpContext = c
|
||||
event.Admin = admin
|
||||
|
||||
// update the admin
|
||||
submitErr := form.Submit(func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
|
||||
return func() error {
|
||||
submitErr := form.Submit(func(next forms.InterceptorNextFunc[*models.Admin]) forms.InterceptorNextFunc[*models.Admin] {
|
||||
return func(m *models.Admin) error {
|
||||
event.Admin = m
|
||||
|
||||
return api.app.OnAdminBeforeUpdateRequest().Trigger(event, func(e *core.AdminUpdateEvent) error {
|
||||
if err := next(); err != nil {
|
||||
return rest.NewBadRequestError("Failed to update admin.", err)
|
||||
if err := next(e.Admin); err != nil {
|
||||
return NewBadRequestError("Failed to update admin.", err)
|
||||
}
|
||||
|
||||
return e.HttpContext.JSON(http.StatusOK, e.Admin)
|
||||
@@ -231,7 +308,9 @@ func (api *adminApi) update(c echo.Context) error {
|
||||
})
|
||||
|
||||
if submitErr == nil {
|
||||
api.app.OnAdminAfterUpdateRequest().Trigger(event)
|
||||
if err := api.app.OnAdminAfterUpdateRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return submitErr
|
||||
@@ -240,29 +319,30 @@ func (api *adminApi) update(c echo.Context) error {
|
||||
func (api *adminApi) delete(c echo.Context) error {
|
||||
id := c.PathParam("id")
|
||||
if id == "" {
|
||||
return rest.NewNotFoundError("", nil)
|
||||
return NewNotFoundError("", nil)
|
||||
}
|
||||
|
||||
admin, err := api.app.Dao().FindAdminById(id)
|
||||
if err != nil || admin == nil {
|
||||
return rest.NewNotFoundError("", err)
|
||||
return NewNotFoundError("", err)
|
||||
}
|
||||
|
||||
event := &core.AdminDeleteEvent{
|
||||
HttpContext: c,
|
||||
Admin: admin,
|
||||
}
|
||||
event := new(core.AdminDeleteEvent)
|
||||
event.HttpContext = c
|
||||
event.Admin = admin
|
||||
|
||||
handlerErr := api.app.OnAdminBeforeDeleteRequest().Trigger(event, func(e *core.AdminDeleteEvent) error {
|
||||
if err := api.app.Dao().DeleteAdmin(e.Admin); err != nil {
|
||||
return rest.NewBadRequestError("Failed to delete admin.", err)
|
||||
return NewBadRequestError("Failed to delete admin.", err)
|
||||
}
|
||||
|
||||
return e.HttpContext.NoContent(http.StatusNoContent)
|
||||
})
|
||||
|
||||
if handlerErr == nil {
|
||||
api.app.OnAdminAfterDeleteRequest().Trigger(event)
|
||||
if err := api.app.OnAdminAfterDeleteRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return handlerErr
|
||||
|
||||
+222
-141
@@ -14,55 +14,79 @@ import (
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
)
|
||||
|
||||
func TestAdminAuth(t *testing.T) {
|
||||
func TestAdminAuthWithPassword(t *testing.T) {
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "empty data",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/admins/auth-via-email",
|
||||
Url: "/api/admins/auth-with-password",
|
||||
Body: strings.NewReader(``),
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{"email":{"code":"validation_required","message":"Cannot be blank."},"password":{"code":"validation_required","message":"Cannot be blank."}}`},
|
||||
ExpectedContent: []string{`"data":{"identity":{"code":"validation_required","message":"Cannot be blank."},"password":{"code":"validation_required","message":"Cannot be blank."}}`},
|
||||
},
|
||||
{
|
||||
Name: "invalid data",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/admins/auth-via-email",
|
||||
Url: "/api/admins/auth-with-password",
|
||||
Body: strings.NewReader(`{`),
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "wrong email/password",
|
||||
Name: "wrong email",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/admins/auth-via-email",
|
||||
Body: strings.NewReader(`{"email":"missing@example.com","password":"wrong_pass"}`),
|
||||
Url: "/api/admins/auth-with-password",
|
||||
Body: strings.NewReader(`{"identity":"missing@example.com","password":"1234567890"}`),
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnAdminBeforeAuthWithPasswordRequest": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "valid email/password (already authorized)",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/admins/auth-via-email",
|
||||
Body: strings.NewReader(`{"email":"test@example.com","password":"1234567890"}`),
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
|
||||
},
|
||||
Name: "wrong password",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/admins/auth-with-password",
|
||||
Body: strings.NewReader(`{"identity":"test@example.com","password":"invalid"}`),
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"message":"The request can be accessed only by guests.","data":{}`},
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnAdminBeforeAuthWithPasswordRequest": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "valid email/password (guest)",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/admins/auth-via-email",
|
||||
Body: strings.NewReader(`{"email":"test@example.com","password":"1234567890"}`),
|
||||
Url: "/api/admins/auth-with-password",
|
||||
Body: strings.NewReader(`{"identity":"test@example.com","password":"1234567890"}`),
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"admin":{"id":"2b4a97cc-3f83-4d01-a26b-3d77bc842d3c"`,
|
||||
`"admin":{"id":"sywbhecnh46rhm0"`,
|
||||
`"token":`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnAdminAuthRequest": 1,
|
||||
"OnAdminBeforeAuthWithPasswordRequest": 1,
|
||||
"OnAdminAfterAuthWithPasswordRequest": 1,
|
||||
"OnAdminAuthRequest": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "valid email/password (already authorized)",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/admins/auth-with-password",
|
||||
Body: strings.NewReader(`{"identity":"test@example.com","password":"1234567890"}`),
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4MTYwMH0.han3_sG65zLddpcX2ic78qgy7FKecuPfOpFa8Dvi5Bg",
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"admin":{"id":"sywbhecnh46rhm0"`,
|
||||
`"token":`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnAdminBeforeAuthWithPasswordRequest": 1,
|
||||
"OnAdminAfterAuthWithPasswordRequest": 1,
|
||||
"OnAdminAuthRequest": 1,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -106,10 +130,12 @@ func TestAdminRequestPasswordReset(t *testing.T) {
|
||||
Delay: 100 * time.Millisecond,
|
||||
ExpectedStatus: 204,
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnModelBeforeUpdate": 1,
|
||||
"OnModelAfterUpdate": 1,
|
||||
"OnMailerBeforeAdminResetPasswordSend": 1,
|
||||
"OnMailerAfterAdminResetPasswordSend": 1,
|
||||
"OnModelBeforeUpdate": 1,
|
||||
"OnModelAfterUpdate": 1,
|
||||
"OnMailerBeforeAdminResetPasswordSend": 1,
|
||||
"OnMailerAfterAdminResetPasswordSend": 1,
|
||||
"OnAdminBeforeRequestPasswordResetRequest": 1,
|
||||
"OnAdminAfterRequestPasswordResetRequest": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -119,7 +145,7 @@ func TestAdminRequestPasswordReset(t *testing.T) {
|
||||
Body: strings.NewReader(`{"email":"test@example.com"}`),
|
||||
Delay: 100 * time.Millisecond,
|
||||
ExpectedStatus: 204,
|
||||
BeforeFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
// simulate recent password request
|
||||
admin, err := app.Dao().FindAdminByEmail("test@example.com")
|
||||
if err != nil {
|
||||
@@ -158,27 +184,44 @@ func TestAdminConfirmPasswordReset(t *testing.T) {
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "expired token",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/admins/confirm-password-reset",
|
||||
Body: strings.NewReader(`{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTY0MTAxMzIwMH0.Gp_1b5WVhqjj2o3nJhNUlJmpdiwFLXN72LbMP-26gjA","password":"1234567890","passwordConfirm":"1234567890"}`),
|
||||
Name: "expired token",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/admins/confirm-password-reset",
|
||||
Body: strings.NewReader(`{
|
||||
"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImV4cCI6MTY0MDk5MTY2MX0.GLwCOsgWTTEKXTK-AyGW838de1OeZGIjfHH0FoRLqZg",
|
||||
"password":"1234567890",
|
||||
"passwordConfirm":"1234567890"
|
||||
}`),
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{"token":{"code":"validation_invalid_token","message":"Invalid or expired token."}}}`},
|
||||
},
|
||||
{
|
||||
Name: "valid token",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/admins/confirm-password-reset",
|
||||
Body: strings.NewReader(`{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg5MzQ3NDAwMH0.72IhlL_5CpNGE0ZKM7sV9aAKa3wxQaMZdDiHBo0orpw","password":"1234567890","passwordConfirm":"1234567890"}`),
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"admin":{"id":"2b4a97cc-3f83-4d01-a26b-3d77bc842d3c"`,
|
||||
`"token":`,
|
||||
},
|
||||
Name: "valid token + invalid password",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/admins/confirm-password-reset",
|
||||
Body: strings.NewReader(`{
|
||||
"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImV4cCI6MjIwODk4MTYwMH0.kwFEler6KSMKJNstuaSDvE1QnNdCta5qSnjaIQ0hhhc",
|
||||
"password":"123456",
|
||||
"passwordConfirm":"123456"
|
||||
}`),
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{"password":{"code":"validation_length_out_of_range"`},
|
||||
},
|
||||
{
|
||||
Name: "valid token + valid password",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/admins/confirm-password-reset",
|
||||
Body: strings.NewReader(`{
|
||||
"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImV4cCI6MjIwODk4MTYwMH0.kwFEler6KSMKJNstuaSDvE1QnNdCta5qSnjaIQ0hhhc",
|
||||
"password":"1234567891",
|
||||
"passwordConfirm":"1234567891"
|
||||
}`),
|
||||
ExpectedStatus: 204,
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnModelBeforeUpdate": 1,
|
||||
"OnModelAfterUpdate": 1,
|
||||
"OnAdminAuthRequest": 1,
|
||||
"OnModelBeforeUpdate": 1,
|
||||
"OnModelAfterUpdate": 1,
|
||||
"OnAdminBeforeConfirmPasswordResetRequest": 1,
|
||||
"OnAdminAfterConfirmPasswordResetRequest": 1,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -193,34 +236,46 @@ func TestAdminRefresh(t *testing.T) {
|
||||
{
|
||||
Name: "unauthorized",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/admins/refresh",
|
||||
Url: "/api/admins/auth-refresh",
|
||||
ExpectedStatus: 401,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "authorized as user",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/admins/refresh",
|
||||
Url: "/api/admins/auth-refresh",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
|
||||
},
|
||||
ExpectedStatus: 401,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "authorized as admin",
|
||||
Name: "authorized as admin (expired token)",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/admins/refresh",
|
||||
Url: "/api/admins/auth-refresh",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTY0MDk5MTY2MX0.I7w8iktkleQvC7_UIRpD7rNzcU4OnF7i7SFIUu6lD_4",
|
||||
},
|
||||
ExpectedStatus: 401,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "authorized as admin (valid token)",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/admins/auth-refresh",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"admin":{"id":"2b4a97cc-3f83-4d01-a26b-3d77bc842d3c"`,
|
||||
`"admin":{"id":"sywbhecnh46rhm0"`,
|
||||
`"token":`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnAdminAuthRequest": 1,
|
||||
"OnAdminAuthRequest": 1,
|
||||
"OnAdminBeforeAuthRefreshRequest": 1,
|
||||
"OnAdminAfterAuthRefreshRequest": 1,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -244,7 +299,7 @@ func TestAdminsList(t *testing.T) {
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/admins",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
|
||||
},
|
||||
ExpectedStatus: 401,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
@@ -254,16 +309,17 @@ func TestAdminsList(t *testing.T) {
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/admins",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"page":1`,
|
||||
`"perPage":30`,
|
||||
`"totalItems":2`,
|
||||
`"totalItems":3`,
|
||||
`"items":[{`,
|
||||
`"id":"2b4a97cc-3f83-4d01-a26b-3d77bc842d3c"`,
|
||||
`"id":"3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8"`,
|
||||
`"id":"sywbhecnh46rhm0"`,
|
||||
`"id":"sbmbsdb40jyxf7h"`,
|
||||
`"id":"9q2trqumvlyr3bd"`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnAdminsListRequest": 1,
|
||||
@@ -274,15 +330,19 @@ func TestAdminsList(t *testing.T) {
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/admins?page=2&perPage=1&sort=-created",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"page":2`,
|
||||
`"perPage":1`,
|
||||
`"totalItems":2`,
|
||||
`"totalItems":3`,
|
||||
`"items":[{`,
|
||||
`"id":"2b4a97cc-3f83-4d01-a26b-3d77bc842d3c"`,
|
||||
`"id":"sbmbsdb40jyxf7h"`,
|
||||
},
|
||||
NotExpectedContent: []string{
|
||||
`"tokenKey"`,
|
||||
`"passwordHash"`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnAdminsListRequest": 1,
|
||||
@@ -293,7 +353,7 @@ func TestAdminsList(t *testing.T) {
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/admins?filter=invalidfield~'test2'",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
@@ -301,9 +361,9 @@ func TestAdminsList(t *testing.T) {
|
||||
{
|
||||
Name: "authorized as admin + valid filter",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/admins?filter=email~'test2'",
|
||||
Url: "/api/admins?filter=email~'test3'",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
@@ -311,7 +371,11 @@ func TestAdminsList(t *testing.T) {
|
||||
`"perPage":30`,
|
||||
`"totalItems":1`,
|
||||
`"items":[{`,
|
||||
`"id":"3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8"`,
|
||||
`"id":"9q2trqumvlyr3bd"`,
|
||||
},
|
||||
NotExpectedContent: []string{
|
||||
`"tokenKey"`,
|
||||
`"passwordHash"`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnAdminsListRequest": 1,
|
||||
@@ -329,36 +393,26 @@ func TestAdminView(t *testing.T) {
|
||||
{
|
||||
Name: "unauthorized",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/admins/3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8",
|
||||
Url: "/api/admins/sbmbsdb40jyxf7h",
|
||||
ExpectedStatus: 401,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "authorized as user",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/admins/3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8",
|
||||
Url: "/api/admins/sbmbsdb40jyxf7h",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
|
||||
},
|
||||
ExpectedStatus: 401,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "authorized as admin + invalid admin id",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/admins/invalid",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
|
||||
},
|
||||
ExpectedStatus: 404,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "authorized as admin + nonexisting admin id",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/admins/b97ccf83-34a2-4d01-a26b-3d77bc842d3c",
|
||||
Url: "/api/admins/nonexisting",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
ExpectedStatus: 404,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
@@ -366,13 +420,17 @@ func TestAdminView(t *testing.T) {
|
||||
{
|
||||
Name: "authorized as admin + existing admin id",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/admins/3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8",
|
||||
Url: "/api/admins/sbmbsdb40jyxf7h",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"id":"3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8"`,
|
||||
`"id":"sbmbsdb40jyxf7h"`,
|
||||
},
|
||||
NotExpectedContent: []string{
|
||||
`"tokenKey"`,
|
||||
`"passwordHash"`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnAdminViewRequest": 1,
|
||||
@@ -390,36 +448,26 @@ func TestAdminDelete(t *testing.T) {
|
||||
{
|
||||
Name: "unauthorized",
|
||||
Method: http.MethodDelete,
|
||||
Url: "/api/admins/3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8",
|
||||
Url: "/api/admins/sbmbsdb40jyxf7h",
|
||||
ExpectedStatus: 401,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "authorized as user",
|
||||
Method: http.MethodDelete,
|
||||
Url: "/api/admins/3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8",
|
||||
Url: "/api/admins/sbmbsdb40jyxf7h",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
|
||||
},
|
||||
ExpectedStatus: 401,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "authorized as admin + invalid admin id",
|
||||
Name: "authorized as admin + missing admin id",
|
||||
Method: http.MethodDelete,
|
||||
Url: "/api/admins/invalid",
|
||||
Url: "/api/admins/missing",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
|
||||
},
|
||||
ExpectedStatus: 404,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "authorized as admin + nonexisting admin id",
|
||||
Method: http.MethodDelete,
|
||||
Url: "/api/admins/b97ccf83-34a2-4d01-a26b-3d77bc842d3c",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
ExpectedStatus: 404,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
@@ -427,9 +475,9 @@ func TestAdminDelete(t *testing.T) {
|
||||
{
|
||||
Name: "authorized as admin + existing admin id",
|
||||
Method: http.MethodDelete,
|
||||
Url: "/api/admins/3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8",
|
||||
Url: "/api/admins/sbmbsdb40jyxf7h",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
ExpectedStatus: 204,
|
||||
ExpectedEvents: map[string]int{
|
||||
@@ -442,15 +490,15 @@ func TestAdminDelete(t *testing.T) {
|
||||
{
|
||||
Name: "authorized as admin - try to delete the only remaining admin",
|
||||
Method: http.MethodDelete,
|
||||
Url: "/api/admins/2b4a97cc-3f83-4d01-a26b-3d77bc842d3c",
|
||||
Url: "/api/admins/sywbhecnh46rhm0",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
BeforeFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
// delete all admins except the authorized one
|
||||
adminModel := &models.Admin{}
|
||||
_, err := app.Dao().DB().Delete(adminModel.TableName(), dbx.Not(dbx.HashExp{
|
||||
"id": "2b4a97cc-3f83-4d01-a26b-3d77bc842d3c",
|
||||
"id": "sywbhecnh46rhm0",
|
||||
})).Execute()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -483,7 +531,7 @@ func TestAdminCreate(t *testing.T) {
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/admins",
|
||||
Body: strings.NewReader(`{"email":"testnew@example.com","password":"1234567890","passwordConfirm":"1234567890","avatar":3}`),
|
||||
BeforeFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
// delete all admins
|
||||
_, err := app.Dao().DB().NewQuery("DELETE FROM {{_admins}}").Execute()
|
||||
if err != nil {
|
||||
@@ -508,7 +556,7 @@ func TestAdminCreate(t *testing.T) {
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/admins",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
|
||||
},
|
||||
ExpectedStatus: 401,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
@@ -519,7 +567,7 @@ func TestAdminCreate(t *testing.T) {
|
||||
Url: "/api/admins",
|
||||
Body: strings.NewReader(``),
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{"email":{"code":"validation_required","message":"Cannot be blank."},"password":{"code":"validation_required","message":"Cannot be blank."}}`},
|
||||
@@ -530,7 +578,7 @@ func TestAdminCreate(t *testing.T) {
|
||||
Url: "/api/admins",
|
||||
Body: strings.NewReader(`{`),
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
@@ -539,20 +587,36 @@ func TestAdminCreate(t *testing.T) {
|
||||
Name: "authorized as admin + invalid data",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/admins",
|
||||
Body: strings.NewReader(`{"email":"test@example.com","password":"1234","passwordConfirm":"4321","avatar":99}`),
|
||||
Body: strings.NewReader(`{
|
||||
"email":"test@example.com",
|
||||
"password":"1234",
|
||||
"passwordConfirm":"4321",
|
||||
"avatar":99
|
||||
}`),
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{
|
||||
`"data":{`,
|
||||
`"avatar":{"code":"validation_max_less_equal_than_required"`,
|
||||
`"email":{"code":"validation_admin_email_exists"`,
|
||||
`"password":{"code":"validation_length_out_of_range"`,
|
||||
`"passwordConfirm":{"code":"validation_values_mismatch"`,
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{"avatar":{"code":"validation_max_less_equal_than_required","message":"Must be no greater than 9."},"email":{"code":"validation_admin_email_exists","message":"Admin email already exists."},"password":{"code":"validation_length_out_of_range","message":"The length must be between 10 and 100."},"passwordConfirm":{"code":"validation_values_mismatch","message":"Values don't match."}}`},
|
||||
},
|
||||
{
|
||||
Name: "authorized as admin + valid data",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/admins",
|
||||
Body: strings.NewReader(`{"email":"testnew@example.com","password":"1234567890","passwordConfirm":"1234567890","avatar":3}`),
|
||||
Body: strings.NewReader(`{
|
||||
"email":"testnew@example.com",
|
||||
"password":"1234567890",
|
||||
"passwordConfirm":"1234567890",
|
||||
"avatar":3
|
||||
}`),
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
@@ -560,6 +624,12 @@ func TestAdminCreate(t *testing.T) {
|
||||
`"email":"testnew@example.com"`,
|
||||
`"avatar":3`,
|
||||
},
|
||||
NotExpectedContent: []string{
|
||||
`"password"`,
|
||||
`"passwordConfirm"`,
|
||||
`"tokenKey"`,
|
||||
`"passwordHash"`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnModelBeforeCreate": 1,
|
||||
"OnModelAfterCreate": 1,
|
||||
@@ -579,38 +649,27 @@ func TestAdminUpdate(t *testing.T) {
|
||||
{
|
||||
Name: "unauthorized",
|
||||
Method: http.MethodPatch,
|
||||
Url: "/api/admins/3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8",
|
||||
Url: "/api/admins/sbmbsdb40jyxf7h",
|
||||
ExpectedStatus: 401,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "authorized as user",
|
||||
Method: http.MethodPatch,
|
||||
Url: "/api/admins/3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8",
|
||||
Url: "/api/admins/sbmbsdb40jyxf7h",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
|
||||
},
|
||||
ExpectedStatus: 401,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "authorized as admin + invalid admin id",
|
||||
Name: "authorized as admin + missing admin",
|
||||
Method: http.MethodPatch,
|
||||
Url: "/api/admins/invalid",
|
||||
Url: "/api/admins/missing",
|
||||
Body: strings.NewReader(``),
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
|
||||
},
|
||||
ExpectedStatus: 404,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "authorized as admin + nonexisting admin id",
|
||||
Method: http.MethodPatch,
|
||||
Url: "/api/admins/b97ccf83-34a2-4d01-a26b-3d77bc842d3c",
|
||||
Body: strings.NewReader(``),
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
ExpectedStatus: 404,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
@@ -618,14 +677,14 @@ func TestAdminUpdate(t *testing.T) {
|
||||
{
|
||||
Name: "authorized as admin + empty data",
|
||||
Method: http.MethodPatch,
|
||||
Url: "/api/admins/3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8",
|
||||
Url: "/api/admins/sbmbsdb40jyxf7h",
|
||||
Body: strings.NewReader(``),
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"id":"3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8"`,
|
||||
`"id":"sbmbsdb40jyxf7h"`,
|
||||
`"email":"test2@example.com"`,
|
||||
`"avatar":2`,
|
||||
},
|
||||
@@ -639,10 +698,10 @@ func TestAdminUpdate(t *testing.T) {
|
||||
{
|
||||
Name: "authorized as admin + invalid formatted data",
|
||||
Method: http.MethodPatch,
|
||||
Url: "/api/admins/3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8",
|
||||
Url: "/api/admins/sbmbsdb40jyxf7h",
|
||||
Body: strings.NewReader(`{`),
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
@@ -650,27 +709,49 @@ func TestAdminUpdate(t *testing.T) {
|
||||
{
|
||||
Name: "authorized as admin + invalid data",
|
||||
Method: http.MethodPatch,
|
||||
Url: "/api/admins/3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8",
|
||||
Body: strings.NewReader(`{"email":"test@example.com","password":"1234","passwordConfirm":"4321","avatar":99}`),
|
||||
Url: "/api/admins/sbmbsdb40jyxf7h",
|
||||
Body: strings.NewReader(`{
|
||||
"email":"test@example.com",
|
||||
"password":"1234",
|
||||
"passwordConfirm":"4321",
|
||||
"avatar":99
|
||||
}`),
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{
|
||||
`"data":{`,
|
||||
`"avatar":{"code":"validation_max_less_equal_than_required"`,
|
||||
`"email":{"code":"validation_admin_email_exists"`,
|
||||
`"password":{"code":"validation_length_out_of_range"`,
|
||||
`"passwordConfirm":{"code":"validation_values_mismatch"`,
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{"avatar":{"code":"validation_max_less_equal_than_required","message":"Must be no greater than 9."},"email":{"code":"validation_admin_email_exists","message":"Admin email already exists."},"password":{"code":"validation_length_out_of_range","message":"The length must be between 10 and 100."},"passwordConfirm":{"code":"validation_values_mismatch","message":"Values don't match."}}`},
|
||||
},
|
||||
{
|
||||
Method: http.MethodPatch,
|
||||
Url: "/api/admins/3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8",
|
||||
Body: strings.NewReader(`{"email":"testnew@example.com","password":"1234567890","passwordConfirm":"1234567890","avatar":5}`),
|
||||
Url: "/api/admins/sbmbsdb40jyxf7h",
|
||||
Body: strings.NewReader(`{
|
||||
"email":"testnew@example.com",
|
||||
"password":"1234567891",
|
||||
"passwordConfirm":"1234567891",
|
||||
"avatar":5
|
||||
}`),
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"id":"3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8"`,
|
||||
`"id":"sbmbsdb40jyxf7h"`,
|
||||
`"email":"testnew@example.com"`,
|
||||
`"avatar":5`,
|
||||
},
|
||||
NotExpectedContent: []string{
|
||||
`"password"`,
|
||||
`"passwordConfirm"`,
|
||||
`"tokenKey"`,
|
||||
`"passwordHash"`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnModelBeforeUpdate": 1,
|
||||
"OnModelAfterUpdate": 1,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package rest
|
||||
package apis
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"github.com/pocketbase/pocketbase/tools/inflector"
|
||||
)
|
||||
|
||||
// ApiError defines the properties for a basic api error response.
|
||||
// ApiError defines the struct for a basic api error response.
|
||||
type ApiError struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
@@ -23,6 +23,7 @@ func (e *ApiError) Error() string {
|
||||
return e.Message
|
||||
}
|
||||
|
||||
// RawData returns the unformatted error data (could be an internal error, text, etc.)
|
||||
func (e *ApiError) RawData() any {
|
||||
return e.rawData
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package rest_test
|
||||
package apis_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
@@ -6,11 +6,11 @@ import (
|
||||
"testing"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/pocketbase/pocketbase/tools/rest"
|
||||
"github.com/pocketbase/pocketbase/apis"
|
||||
)
|
||||
|
||||
func TestNewApiErrorWithRawData(t *testing.T) {
|
||||
e := rest.NewApiError(
|
||||
e := apis.NewApiError(
|
||||
300,
|
||||
"message_test",
|
||||
"rawData_test",
|
||||
@@ -33,7 +33,7 @@ func TestNewApiErrorWithRawData(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestNewApiErrorWithValidationData(t *testing.T) {
|
||||
e := rest.NewApiError(
|
||||
e := apis.NewApiError(
|
||||
300,
|
||||
"message_test",
|
||||
validation.Errors{
|
||||
@@ -77,7 +77,7 @@ func TestNewNotFoundError(t *testing.T) {
|
||||
}
|
||||
|
||||
for i, scenario := range scenarios {
|
||||
e := rest.NewNotFoundError(scenario.message, scenario.data)
|
||||
e := apis.NewNotFoundError(scenario.message, scenario.data)
|
||||
result, _ := json.Marshal(e)
|
||||
|
||||
if string(result) != scenario.expected {
|
||||
@@ -98,7 +98,7 @@ func TestNewBadRequestError(t *testing.T) {
|
||||
}
|
||||
|
||||
for i, scenario := range scenarios {
|
||||
e := rest.NewBadRequestError(scenario.message, scenario.data)
|
||||
e := apis.NewBadRequestError(scenario.message, scenario.data)
|
||||
result, _ := json.Marshal(e)
|
||||
|
||||
if string(result) != scenario.expected {
|
||||
@@ -119,7 +119,7 @@ func TestNewForbiddenError(t *testing.T) {
|
||||
}
|
||||
|
||||
for i, scenario := range scenarios {
|
||||
e := rest.NewForbiddenError(scenario.message, scenario.data)
|
||||
e := apis.NewForbiddenError(scenario.message, scenario.data)
|
||||
result, _ := json.Marshal(e)
|
||||
|
||||
if string(result) != scenario.expected {
|
||||
@@ -140,7 +140,7 @@ func TestNewUnauthorizedError(t *testing.T) {
|
||||
}
|
||||
|
||||
for i, scenario := range scenarios {
|
||||
e := rest.NewUnauthorizedError(scenario.message, scenario.data)
|
||||
e := apis.NewUnauthorizedError(scenario.message, scenario.data)
|
||||
result, _ := json.Marshal(e)
|
||||
|
||||
if string(result) != scenario.expected {
|
||||
+54
-30
@@ -2,6 +2,7 @@
|
||||
package apis
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log"
|
||||
@@ -13,7 +14,6 @@ import (
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/labstack/echo/v5/middleware"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tools/rest"
|
||||
"github.com/pocketbase/pocketbase/ui"
|
||||
"github.com/spf13/cast"
|
||||
)
|
||||
@@ -26,6 +26,13 @@ func InitApi(app core.App) (*echo.Echo, error) {
|
||||
e := echo.New()
|
||||
e.Debug = app.IsDebug()
|
||||
|
||||
// configure a custom router
|
||||
e.ResetRouterCreator(func(ec *echo.Echo) echo.Router {
|
||||
return echo.NewRouter(echo.RouterConfig{
|
||||
UnescapePathParamValues: true,
|
||||
})
|
||||
})
|
||||
|
||||
// default middlewares
|
||||
e.Pre(middleware.RemoveTrailingSlashWithConfig(middleware.RemoveTrailingSlashConfig{
|
||||
Skipper: func(c echo.Context) bool {
|
||||
@@ -43,7 +50,7 @@ func InitApi(app core.App) (*echo.Echo, error) {
|
||||
return
|
||||
}
|
||||
|
||||
var apiErr *rest.ApiError
|
||||
var apiErr *ApiError
|
||||
|
||||
switch v := err.(type) {
|
||||
case *echo.HTTPError:
|
||||
@@ -51,8 +58,8 @@ func InitApi(app core.App) (*echo.Echo, error) {
|
||||
log.Println(v.Internal)
|
||||
}
|
||||
msg := fmt.Sprintf("%v", v.Message)
|
||||
apiErr = rest.NewApiError(v.Code, msg, v)
|
||||
case *rest.ApiError:
|
||||
apiErr = NewApiError(v.Code, msg, v)
|
||||
case *ApiError:
|
||||
if app.IsDebug() && v.RawData() != nil {
|
||||
log.Println(v.RawData())
|
||||
}
|
||||
@@ -61,22 +68,29 @@ func InitApi(app core.App) (*echo.Echo, error) {
|
||||
if err != nil && app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
apiErr = rest.NewBadRequestError("", err)
|
||||
apiErr = NewBadRequestError("", err)
|
||||
}
|
||||
|
||||
// Send response
|
||||
var cErr error
|
||||
if c.Request().Method == http.MethodHead {
|
||||
event := new(core.ApiErrorEvent)
|
||||
event.HttpContext = c
|
||||
event.Error = apiErr
|
||||
|
||||
// send error response
|
||||
hookErr := app.OnBeforeApiError().Trigger(event, func(e *core.ApiErrorEvent) error {
|
||||
// @see https://github.com/labstack/echo/issues/608
|
||||
cErr = c.NoContent(apiErr.Code)
|
||||
} else {
|
||||
cErr = c.JSON(apiErr.Code, apiErr)
|
||||
}
|
||||
if e.HttpContext.Request().Method == http.MethodHead {
|
||||
return e.HttpContext.NoContent(apiErr.Code)
|
||||
}
|
||||
|
||||
return e.HttpContext.JSON(apiErr.Code, apiErr)
|
||||
})
|
||||
|
||||
// truly rare case; eg. client already disconnected
|
||||
if cErr != nil && app.IsDebug() {
|
||||
log.Println(cErr)
|
||||
if hookErr != nil && app.IsDebug() {
|
||||
log.Println(hookErr)
|
||||
}
|
||||
|
||||
app.OnAfterApiError().Trigger(event)
|
||||
}
|
||||
|
||||
// admin ui routes
|
||||
@@ -84,14 +98,15 @@ func InitApi(app core.App) (*echo.Echo, error) {
|
||||
|
||||
// default routes
|
||||
api := e.Group("/api")
|
||||
BindSettingsApi(app, api)
|
||||
BindAdminApi(app, api)
|
||||
BindUserApi(app, api)
|
||||
BindCollectionApi(app, api)
|
||||
BindRecordApi(app, api)
|
||||
BindFileApi(app, api)
|
||||
BindRealtimeApi(app, api)
|
||||
BindLogsApi(app, api)
|
||||
bindSettingsApi(app, api)
|
||||
bindAdminApi(app, api)
|
||||
bindCollectionApi(app, api)
|
||||
bindRecordCrudApi(app, api)
|
||||
bindRecordAuthApi(app, api)
|
||||
bindFileApi(app, api)
|
||||
bindRealtimeApi(app, api)
|
||||
bindLogsApi(app, api)
|
||||
bindHealthApi(app, api)
|
||||
|
||||
// trigger the custom BeforeServe hook for the created api router
|
||||
// allowing users to further adjust its options or register new routes
|
||||
@@ -114,22 +129,31 @@ func InitApi(app core.App) (*echo.Echo, error) {
|
||||
// StaticDirectoryHandler is similar to `echo.StaticDirectoryHandler`
|
||||
// but without the directory redirect which conflicts with RemoveTrailingSlash middleware.
|
||||
//
|
||||
// If a file resource is missing and indexFallback is set, the request
|
||||
// will be forwarded to the base index.html (useful also for SPA).
|
||||
//
|
||||
// @see https://github.com/labstack/echo/issues/2211
|
||||
func StaticDirectoryHandler(fileSystem fs.FS, disablePathUnescaping bool) echo.HandlerFunc {
|
||||
func StaticDirectoryHandler(fileSystem fs.FS, indexFallback bool) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
p := c.PathParam("*")
|
||||
if !disablePathUnescaping { // when router is already unescaping we do not want to do is twice
|
||||
tmpPath, err := url.PathUnescape(p)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to unescape path variable: %w", err)
|
||||
}
|
||||
p = tmpPath
|
||||
|
||||
// escape url path
|
||||
tmpPath, err := url.PathUnescape(p)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to unescape path variable: %w", err)
|
||||
}
|
||||
p = tmpPath
|
||||
|
||||
// fs.FS.Open() already assumes that file names are relative to FS root path and considers name with prefix `/` as invalid
|
||||
name := filepath.ToSlash(filepath.Clean(strings.TrimPrefix(p, "/")))
|
||||
|
||||
return c.FileFS(name, fileSystem)
|
||||
fileErr := c.FileFS(name, fileSystem)
|
||||
|
||||
if fileErr != nil && indexFallback && errors.Is(fileErr, echo.ErrNotFound) {
|
||||
return c.FileFS("index.html", fileSystem)
|
||||
}
|
||||
|
||||
return fileErr
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+22
-6
@@ -6,8 +6,8 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/pocketbase/pocketbase/apis"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
"github.com/pocketbase/pocketbase/tools/rest"
|
||||
)
|
||||
|
||||
func Test404(t *testing.T) {
|
||||
@@ -54,7 +54,7 @@ func TestCustomRoutesAndErrorsHandling(t *testing.T) {
|
||||
Name: "custom route",
|
||||
Method: http.MethodGet,
|
||||
Url: "/custom",
|
||||
BeforeFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
e.AddRoute(echo.Route{
|
||||
Method: http.MethodGet,
|
||||
Path: "/custom",
|
||||
@@ -66,11 +66,27 @@ func TestCustomRoutesAndErrorsHandling(t *testing.T) {
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{"test123"},
|
||||
},
|
||||
{
|
||||
Name: "custom route with url encoded parameter",
|
||||
Method: http.MethodGet,
|
||||
Url: "/a%2Bb%2Bc",
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
e.AddRoute(echo.Route{
|
||||
Method: http.MethodGet,
|
||||
Path: "/:param",
|
||||
Handler: func(c echo.Context) error {
|
||||
return c.String(200, c.PathParam("param"))
|
||||
},
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{"a+b+c"},
|
||||
},
|
||||
{
|
||||
Name: "route with HTTPError",
|
||||
Method: http.MethodGet,
|
||||
Url: "/http-error",
|
||||
BeforeFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
e.AddRoute(echo.Route{
|
||||
Method: http.MethodGet,
|
||||
Path: "/http-error",
|
||||
@@ -86,12 +102,12 @@ func TestCustomRoutesAndErrorsHandling(t *testing.T) {
|
||||
Name: "route with api error",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api-error",
|
||||
BeforeFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
e.AddRoute(echo.Route{
|
||||
Method: http.MethodGet,
|
||||
Path: "/api-error",
|
||||
Handler: func(c echo.Context) error {
|
||||
return rest.NewApiError(500, "test message", errors.New("internal_test"))
|
||||
return apis.NewApiError(500, "test message", errors.New("internal_test"))
|
||||
},
|
||||
})
|
||||
},
|
||||
@@ -102,7 +118,7 @@ func TestCustomRoutesAndErrorsHandling(t *testing.T) {
|
||||
Name: "route with plain error",
|
||||
Method: http.MethodGet,
|
||||
Url: "/plain-error",
|
||||
BeforeFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
e.AddRoute(echo.Route{
|
||||
Method: http.MethodGet,
|
||||
Path: "/plain-error",
|
||||
|
||||
+62
-56
@@ -1,18 +1,18 @@
|
||||
package apis
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/forms"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/tools/rest"
|
||||
"github.com/pocketbase/pocketbase/tools/search"
|
||||
)
|
||||
|
||||
// BindCollectionApi registers the collection api endpoints and the corresponding handlers.
|
||||
func BindCollectionApi(app core.App, rg *echo.Group) {
|
||||
// bindCollectionApi registers the collection api endpoints and the corresponding handlers.
|
||||
func bindCollectionApi(app core.App, rg *echo.Group) {
|
||||
api := collectionApi{app: app}
|
||||
|
||||
subGroup := rg.Group("/collections", ActivityLogger(app), RequireAdminAuth())
|
||||
@@ -30,24 +30,23 @@ type collectionApi struct {
|
||||
|
||||
func (api *collectionApi) list(c echo.Context) error {
|
||||
fieldResolver := search.NewSimpleFieldResolver(
|
||||
"id", "created", "updated", "name", "system",
|
||||
"id", "created", "updated", "name", "system", "type",
|
||||
)
|
||||
|
||||
collections := []*models.Collection{}
|
||||
|
||||
result, err := search.NewProvider(fieldResolver).
|
||||
Query(api.app.Dao().CollectionQuery()).
|
||||
ParseAndExec(c.QueryString(), &collections)
|
||||
ParseAndExec(c.QueryParams().Encode(), &collections)
|
||||
|
||||
if err != nil {
|
||||
return rest.NewBadRequestError("", err)
|
||||
return NewBadRequestError("", err)
|
||||
}
|
||||
|
||||
event := &core.CollectionsListEvent{
|
||||
HttpContext: c,
|
||||
Collections: collections,
|
||||
Result: result,
|
||||
}
|
||||
event := new(core.CollectionsListEvent)
|
||||
event.HttpContext = c
|
||||
event.Collections = collections
|
||||
event.Result = result
|
||||
|
||||
return api.app.OnCollectionsListRequest().Trigger(event, func(e *core.CollectionsListEvent) error {
|
||||
return e.HttpContext.JSON(http.StatusOK, e.Result)
|
||||
@@ -57,13 +56,12 @@ func (api *collectionApi) list(c echo.Context) error {
|
||||
func (api *collectionApi) view(c echo.Context) error {
|
||||
collection, err := api.app.Dao().FindCollectionByNameOrId(c.PathParam("collection"))
|
||||
if err != nil || collection == nil {
|
||||
return rest.NewNotFoundError("", err)
|
||||
return NewNotFoundError("", err)
|
||||
}
|
||||
|
||||
event := &core.CollectionViewEvent{
|
||||
HttpContext: c,
|
||||
Collection: collection,
|
||||
}
|
||||
event := new(core.CollectionViewEvent)
|
||||
event.HttpContext = c
|
||||
event.Collection = collection
|
||||
|
||||
return api.app.OnCollectionViewRequest().Trigger(event, func(e *core.CollectionViewEvent) error {
|
||||
return e.HttpContext.JSON(http.StatusOK, e.Collection)
|
||||
@@ -77,20 +75,21 @@ func (api *collectionApi) create(c echo.Context) error {
|
||||
|
||||
// load request
|
||||
if err := c.Bind(form); err != nil {
|
||||
return rest.NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
|
||||
return NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
|
||||
}
|
||||
|
||||
event := &core.CollectionCreateEvent{
|
||||
HttpContext: c,
|
||||
Collection: collection,
|
||||
}
|
||||
event := new(core.CollectionCreateEvent)
|
||||
event.HttpContext = c
|
||||
event.Collection = collection
|
||||
|
||||
// create the collection
|
||||
submitErr := form.Submit(func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
|
||||
return func() error {
|
||||
submitErr := form.Submit(func(next forms.InterceptorNextFunc[*models.Collection]) forms.InterceptorNextFunc[*models.Collection] {
|
||||
return func(m *models.Collection) error {
|
||||
event.Collection = m
|
||||
|
||||
return api.app.OnCollectionBeforeCreateRequest().Trigger(event, func(e *core.CollectionCreateEvent) error {
|
||||
if err := next(); err != nil {
|
||||
return rest.NewBadRequestError("Failed to create the collection.", err)
|
||||
if err := next(e.Collection); err != nil {
|
||||
return NewBadRequestError("Failed to create the collection.", err)
|
||||
}
|
||||
|
||||
return e.HttpContext.JSON(http.StatusOK, e.Collection)
|
||||
@@ -99,7 +98,9 @@ func (api *collectionApi) create(c echo.Context) error {
|
||||
})
|
||||
|
||||
if submitErr == nil {
|
||||
api.app.OnCollectionAfterCreateRequest().Trigger(event)
|
||||
if err := api.app.OnCollectionAfterCreateRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return submitErr
|
||||
@@ -108,27 +109,28 @@ func (api *collectionApi) create(c echo.Context) error {
|
||||
func (api *collectionApi) update(c echo.Context) error {
|
||||
collection, err := api.app.Dao().FindCollectionByNameOrId(c.PathParam("collection"))
|
||||
if err != nil || collection == nil {
|
||||
return rest.NewNotFoundError("", err)
|
||||
return NewNotFoundError("", err)
|
||||
}
|
||||
|
||||
form := forms.NewCollectionUpsert(api.app, collection)
|
||||
|
||||
// load request
|
||||
if err := c.Bind(form); err != nil {
|
||||
return rest.NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
|
||||
return NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
|
||||
}
|
||||
|
||||
event := &core.CollectionUpdateEvent{
|
||||
HttpContext: c,
|
||||
Collection: collection,
|
||||
}
|
||||
event := new(core.CollectionUpdateEvent)
|
||||
event.HttpContext = c
|
||||
event.Collection = collection
|
||||
|
||||
// update the collection
|
||||
submitErr := form.Submit(func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
|
||||
return func() error {
|
||||
submitErr := form.Submit(func(next forms.InterceptorNextFunc[*models.Collection]) forms.InterceptorNextFunc[*models.Collection] {
|
||||
return func(m *models.Collection) error {
|
||||
event.Collection = m
|
||||
|
||||
return api.app.OnCollectionBeforeUpdateRequest().Trigger(event, func(e *core.CollectionUpdateEvent) error {
|
||||
if err := next(); err != nil {
|
||||
return rest.NewBadRequestError("Failed to update the collection.", err)
|
||||
if err := next(e.Collection); err != nil {
|
||||
return NewBadRequestError("Failed to update the collection.", err)
|
||||
}
|
||||
|
||||
return e.HttpContext.JSON(http.StatusOK, e.Collection)
|
||||
@@ -137,7 +139,9 @@ func (api *collectionApi) update(c echo.Context) error {
|
||||
})
|
||||
|
||||
if submitErr == nil {
|
||||
api.app.OnCollectionAfterUpdateRequest().Trigger(event)
|
||||
if err := api.app.OnCollectionAfterUpdateRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return submitErr
|
||||
@@ -146,24 +150,25 @@ func (api *collectionApi) update(c echo.Context) error {
|
||||
func (api *collectionApi) delete(c echo.Context) error {
|
||||
collection, err := api.app.Dao().FindCollectionByNameOrId(c.PathParam("collection"))
|
||||
if err != nil || collection == nil {
|
||||
return rest.NewNotFoundError("", err)
|
||||
return NewNotFoundError("", err)
|
||||
}
|
||||
|
||||
event := &core.CollectionDeleteEvent{
|
||||
HttpContext: c,
|
||||
Collection: collection,
|
||||
}
|
||||
event := new(core.CollectionDeleteEvent)
|
||||
event.HttpContext = c
|
||||
event.Collection = collection
|
||||
|
||||
handlerErr := api.app.OnCollectionBeforeDeleteRequest().Trigger(event, func(e *core.CollectionDeleteEvent) error {
|
||||
if err := api.app.Dao().DeleteCollection(e.Collection); err != nil {
|
||||
return rest.NewBadRequestError("Failed to delete collection. Make sure that the collection is not referenced by other collections.", err)
|
||||
return NewBadRequestError("Failed to delete collection. Make sure that the collection is not referenced by other collections.", err)
|
||||
}
|
||||
|
||||
return e.HttpContext.NoContent(http.StatusNoContent)
|
||||
})
|
||||
|
||||
if handlerErr == nil {
|
||||
api.app.OnCollectionAfterDeleteRequest().Trigger(event)
|
||||
if err := api.app.OnCollectionAfterDeleteRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return handlerErr
|
||||
@@ -174,22 +179,21 @@ func (api *collectionApi) bulkImport(c echo.Context) error {
|
||||
|
||||
// load request data
|
||||
if err := c.Bind(form); err != nil {
|
||||
return rest.NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
|
||||
return NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
|
||||
}
|
||||
|
||||
event := &core.CollectionsImportEvent{
|
||||
HttpContext: c,
|
||||
Collections: form.Collections,
|
||||
}
|
||||
event := new(core.CollectionsImportEvent)
|
||||
event.HttpContext = c
|
||||
event.Collections = form.Collections
|
||||
|
||||
// import collections
|
||||
submitErr := form.Submit(func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
|
||||
return func() error {
|
||||
return api.app.OnCollectionsBeforeImportRequest().Trigger(event, func(e *core.CollectionsImportEvent) error {
|
||||
form.Collections = e.Collections // ensures that the form always has the latest changes
|
||||
submitErr := form.Submit(func(next forms.InterceptorNextFunc[[]*models.Collection]) forms.InterceptorNextFunc[[]*models.Collection] {
|
||||
return func(imports []*models.Collection) error {
|
||||
event.Collections = imports
|
||||
|
||||
if err := next(); err != nil {
|
||||
return rest.NewBadRequestError("Failed to import the submitted collections.", err)
|
||||
return api.app.OnCollectionsBeforeImportRequest().Trigger(event, func(e *core.CollectionsImportEvent) error {
|
||||
if err := next(e.Collections); err != nil {
|
||||
return NewBadRequestError("Failed to import the submitted collections.", err)
|
||||
}
|
||||
|
||||
return e.HttpContext.NoContent(http.StatusNoContent)
|
||||
@@ -198,7 +202,9 @@ func (api *collectionApi) bulkImport(c echo.Context) error {
|
||||
})
|
||||
|
||||
if submitErr == nil {
|
||||
api.app.OnCollectionsAfterImportRequest().Trigger(event)
|
||||
if err := api.app.OnCollectionsAfterImportRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return submitErr
|
||||
|
||||
+544
-141
File diff suppressed because it is too large
Load Diff
+22
-22
@@ -6,14 +6,13 @@ import (
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/models/schema"
|
||||
"github.com/pocketbase/pocketbase/tools/list"
|
||||
"github.com/pocketbase/pocketbase/tools/rest"
|
||||
)
|
||||
|
||||
var imageContentTypes = []string{"image/png", "image/jpg", "image/jpeg"}
|
||||
var imageContentTypes = []string{"image/png", "image/jpg", "image/jpeg", "image/gif"}
|
||||
var defaultThumbSizes = []string{"100x100"}
|
||||
|
||||
// BindFileApi registers the file api endpoints and the corresponding handlers.
|
||||
func BindFileApi(app core.App, rg *echo.Group) {
|
||||
// bindFileApi registers the file api endpoints and the corresponding handlers.
|
||||
func bindFileApi(app core.App, rg *echo.Group) {
|
||||
api := fileApi{app: app}
|
||||
|
||||
subGroup := rg.Group("/files", ActivityLogger(app))
|
||||
@@ -27,30 +26,30 @@ type fileApi struct {
|
||||
func (api *fileApi) download(c echo.Context) error {
|
||||
collection, _ := c.Get(ContextCollectionKey).(*models.Collection)
|
||||
if collection == nil {
|
||||
return rest.NewNotFoundError("", nil)
|
||||
return NewNotFoundError("", nil)
|
||||
}
|
||||
|
||||
recordId := c.PathParam("recordId")
|
||||
if recordId == "" {
|
||||
return rest.NewNotFoundError("", nil)
|
||||
return NewNotFoundError("", nil)
|
||||
}
|
||||
|
||||
record, err := api.app.Dao().FindRecordById(collection, recordId, nil)
|
||||
record, err := api.app.Dao().FindRecordById(collection.Id, recordId)
|
||||
if err != nil {
|
||||
return rest.NewNotFoundError("", err)
|
||||
return NewNotFoundError("", err)
|
||||
}
|
||||
|
||||
filename := c.PathParam("filename")
|
||||
|
||||
fileField := record.FindFileFieldByFile(filename)
|
||||
if fileField == nil {
|
||||
return rest.NewNotFoundError("", nil)
|
||||
return NewNotFoundError("", nil)
|
||||
}
|
||||
options, _ := fileField.Options.(*schema.FileOptions)
|
||||
|
||||
fs, err := api.app.NewFilesystem()
|
||||
if err != nil {
|
||||
return rest.NewBadRequestError("Filesystem initialization failure.", err)
|
||||
return NewBadRequestError("Filesystem initialization failure.", err)
|
||||
}
|
||||
defer fs.Close()
|
||||
|
||||
@@ -64,7 +63,7 @@ func (api *fileApi) download(c echo.Context) error {
|
||||
// extract the original file meta attributes and check it existence
|
||||
oAttrs, oAttrsErr := fs.Attributes(originalPath)
|
||||
if oAttrsErr != nil {
|
||||
return rest.NewNotFoundError("", err)
|
||||
return NewNotFoundError("", err)
|
||||
}
|
||||
|
||||
// check if it is an image
|
||||
@@ -78,25 +77,26 @@ func (api *fileApi) download(c echo.Context) error {
|
||||
// - if exists - compare last modified dates to determine whether the thumb should be recreated
|
||||
tAttrs, tAttrsErr := fs.Attributes(servedPath)
|
||||
if tAttrsErr != nil || oAttrs.ModTime.After(tAttrs.ModTime) {
|
||||
if err := fs.CreateThumb(originalPath, servedPath, thumbSize, false); err != nil {
|
||||
if err := fs.CreateThumb(originalPath, servedPath, thumbSize); err != nil {
|
||||
servedPath = originalPath // fallback to the original
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
event := &core.FileDownloadEvent{
|
||||
HttpContext: c,
|
||||
Record: record,
|
||||
Collection: collection,
|
||||
FileField: fileField,
|
||||
ServedPath: servedPath,
|
||||
ServedName: servedName,
|
||||
}
|
||||
event := new(core.FileDownloadEvent)
|
||||
event.HttpContext = c
|
||||
event.Collection = collection
|
||||
event.Record = record
|
||||
event.FileField = fileField
|
||||
event.ServedPath = servedPath
|
||||
event.ServedName = servedName
|
||||
|
||||
return api.app.OnFileDownloadRequest().Trigger(event, func(e *core.FileDownloadEvent) error {
|
||||
if err := fs.Serve(e.HttpContext.Response(), e.ServedPath, e.ServedName); err != nil {
|
||||
return rest.NewNotFoundError("", err)
|
||||
res := e.HttpContext.Response()
|
||||
req := e.HttpContext.Request()
|
||||
if err := fs.Serve(res, req, e.ServedPath, e.ServedName); err != nil {
|
||||
return NewNotFoundError("", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
+94
-13
@@ -14,9 +14,15 @@ import (
|
||||
func TestFileDownload(t *testing.T) {
|
||||
_, currentFile, _, _ := runtime.Caller(0)
|
||||
dataDirRelPath := "../tests/data/"
|
||||
testFilePath := filepath.Join(path.Dir(currentFile), dataDirRelPath, "storage/3f2888f8-075d-49fe-9d09-ea7e951000dc/848a1dea-5ddd-42d6-a00d-030547bffcfe/8fe61d65-6a2e-4f11-87b3-d8a3170bfd4f.txt")
|
||||
testImgPath := filepath.Join(path.Dir(currentFile), dataDirRelPath, "storage/3f2888f8-075d-49fe-9d09-ea7e951000dc/577bd676-aacb-4072-b7da-99d00ee210a4/4881bdef-06b4-4dea-8d97-6125ad242677.png")
|
||||
testThumbPath := filepath.Join(path.Dir(currentFile), dataDirRelPath, "storage/3f2888f8-075d-49fe-9d09-ea7e951000dc/577bd676-aacb-4072-b7da-99d00ee210a4/thumbs_4881bdef-06b4-4dea-8d97-6125ad242677.png/100x100_4881bdef-06b4-4dea-8d97-6125ad242677.png")
|
||||
|
||||
testFilePath := filepath.Join(path.Dir(currentFile), dataDirRelPath, "storage/_pb_users_auth_/oap640cot4yru2s/test_kfd2wYLxkz.txt")
|
||||
testImgPath := filepath.Join(path.Dir(currentFile), dataDirRelPath, "storage/_pb_users_auth_/4q1xlclmfloku33/300_1SEi6Q6U72.png")
|
||||
testThumbCropCenterPath := filepath.Join(path.Dir(currentFile), dataDirRelPath, "storage/_pb_users_auth_/4q1xlclmfloku33/thumbs_300_1SEi6Q6U72.png/70x50_300_1SEi6Q6U72.png")
|
||||
testThumbCropTopPath := filepath.Join(path.Dir(currentFile), dataDirRelPath, "storage/_pb_users_auth_/4q1xlclmfloku33/thumbs_300_1SEi6Q6U72.png/70x50t_300_1SEi6Q6U72.png")
|
||||
testThumbCropBottomPath := filepath.Join(path.Dir(currentFile), dataDirRelPath, "storage/_pb_users_auth_/4q1xlclmfloku33/thumbs_300_1SEi6Q6U72.png/70x50b_300_1SEi6Q6U72.png")
|
||||
testThumbFitPath := filepath.Join(path.Dir(currentFile), dataDirRelPath, "storage/_pb_users_auth_/4q1xlclmfloku33/thumbs_300_1SEi6Q6U72.png/70x50f_300_1SEi6Q6U72.png")
|
||||
testThumbZeroWidthPath := filepath.Join(path.Dir(currentFile), dataDirRelPath, "storage/_pb_users_auth_/4q1xlclmfloku33/thumbs_300_1SEi6Q6U72.png/0x50_300_1SEi6Q6U72.png")
|
||||
testThumbZeroHeightPath := filepath.Join(path.Dir(currentFile), dataDirRelPath, "storage/_pb_users_auth_/4q1xlclmfloku33/thumbs_300_1SEi6Q6U72.png/70x0_300_1SEi6Q6U72.png")
|
||||
|
||||
testFile, fileErr := os.ReadFile(testFilePath)
|
||||
if fileErr != nil {
|
||||
@@ -28,7 +34,32 @@ func TestFileDownload(t *testing.T) {
|
||||
t.Fatal(imgErr)
|
||||
}
|
||||
|
||||
testThumb, thumbErr := os.ReadFile(testThumbPath)
|
||||
testThumbCropCenter, thumbErr := os.ReadFile(testThumbCropCenterPath)
|
||||
if thumbErr != nil {
|
||||
t.Fatal(thumbErr)
|
||||
}
|
||||
|
||||
testThumbCropTop, thumbErr := os.ReadFile(testThumbCropTopPath)
|
||||
if thumbErr != nil {
|
||||
t.Fatal(thumbErr)
|
||||
}
|
||||
|
||||
testThumbCropBottom, thumbErr := os.ReadFile(testThumbCropBottomPath)
|
||||
if thumbErr != nil {
|
||||
t.Fatal(thumbErr)
|
||||
}
|
||||
|
||||
testThumbFit, thumbErr := os.ReadFile(testThumbFitPath)
|
||||
if thumbErr != nil {
|
||||
t.Fatal(thumbErr)
|
||||
}
|
||||
|
||||
testThumbZeroWidth, thumbErr := os.ReadFile(testThumbZeroWidthPath)
|
||||
if thumbErr != nil {
|
||||
t.Fatal(thumbErr)
|
||||
}
|
||||
|
||||
testThumbZeroHeight, thumbErr := os.ReadFile(testThumbZeroHeightPath)
|
||||
if thumbErr != nil {
|
||||
t.Fatal(thumbErr)
|
||||
}
|
||||
@@ -37,28 +68,28 @@ func TestFileDownload(t *testing.T) {
|
||||
{
|
||||
Name: "missing collection",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/files/missing/577bd676-aacb-4072-b7da-99d00ee210a4/4881bdef-06b4-4dea-8d97-6125ad242677.png",
|
||||
Url: "/api/files/missing/4q1xlclmfloku33/300_1SEi6Q6U72.png",
|
||||
ExpectedStatus: 404,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "missing record",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/files/demo/00000000-aacb-4072-b7da-99d00ee210a4/4881bdef-06b4-4dea-8d97-6125ad242677.png",
|
||||
Url: "/api/files/_pb_users_auth_/missing/300_1SEi6Q6U72.png",
|
||||
ExpectedStatus: 404,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "missing file",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/files/demo/577bd676-aacb-4072-b7da-99d00ee210a4/00000000-06b4-4dea-8d97-6125ad242677.png",
|
||||
Url: "/api/files/_pb_users_auth_/4q1xlclmfloku33/missing.png",
|
||||
ExpectedStatus: 404,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "existing image",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/files/demo/577bd676-aacb-4072-b7da-99d00ee210a4/4881bdef-06b4-4dea-8d97-6125ad242677.png",
|
||||
Url: "/api/files/_pb_users_auth_/4q1xlclmfloku33/300_1SEi6Q6U72.png",
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{string(testImg)},
|
||||
ExpectedEvents: map[string]int{
|
||||
@@ -68,7 +99,7 @@ func TestFileDownload(t *testing.T) {
|
||||
{
|
||||
Name: "existing image - missing thumb (should fallback to the original)",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/files/demo/577bd676-aacb-4072-b7da-99d00ee210a4/4881bdef-06b4-4dea-8d97-6125ad242677.png?thumb=999x999",
|
||||
Url: "/api/files/_pb_users_auth_/4q1xlclmfloku33/300_1SEi6Q6U72.png?thumb=999x999",
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{string(testImg)},
|
||||
ExpectedEvents: map[string]int{
|
||||
@@ -76,11 +107,61 @@ func TestFileDownload(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "existing image - existing thumb",
|
||||
Name: "existing image - existing thumb (crop center)",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/files/demo/577bd676-aacb-4072-b7da-99d00ee210a4/4881bdef-06b4-4dea-8d97-6125ad242677.png?thumb=100x100",
|
||||
Url: "/api/files/_pb_users_auth_/4q1xlclmfloku33/300_1SEi6Q6U72.png?thumb=70x50",
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{string(testThumb)},
|
||||
ExpectedContent: []string{string(testThumbCropCenter)},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnFileDownloadRequest": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "existing image - existing thumb (crop top)",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/files/_pb_users_auth_/4q1xlclmfloku33/300_1SEi6Q6U72.png?thumb=70x50t",
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{string(testThumbCropTop)},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnFileDownloadRequest": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "existing image - existing thumb (crop bottom)",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/files/_pb_users_auth_/4q1xlclmfloku33/300_1SEi6Q6U72.png?thumb=70x50b",
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{string(testThumbCropBottom)},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnFileDownloadRequest": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "existing image - existing thumb (fit)",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/files/_pb_users_auth_/4q1xlclmfloku33/300_1SEi6Q6U72.png?thumb=70x50f",
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{string(testThumbFit)},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnFileDownloadRequest": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "existing image - existing thumb (zero width)",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/files/_pb_users_auth_/4q1xlclmfloku33/300_1SEi6Q6U72.png?thumb=0x50",
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{string(testThumbZeroWidth)},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnFileDownloadRequest": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "existing image - existing thumb (zero height)",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/files/_pb_users_auth_/4q1xlclmfloku33/300_1SEi6Q6U72.png?thumb=70x0",
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{string(testThumbZeroHeight)},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnFileDownloadRequest": 1,
|
||||
},
|
||||
@@ -88,7 +169,7 @@ func TestFileDownload(t *testing.T) {
|
||||
{
|
||||
Name: "existing non image file - thumb parameter should be ignored",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/files/demo/848a1dea-5ddd-42d6-a00d-030547bffcfe/8fe61d65-6a2e-4f11-87b3-d8a3170bfd4f.txt?thumb=100x100",
|
||||
Url: "/api/files/_pb_users_auth_/oap640cot4yru2s/test_kfd2wYLxkz.txt?thumb=100x100",
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{string(testFile)},
|
||||
ExpectedEvents: map[string]int{
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package apis
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
)
|
||||
|
||||
// bindHealthApi registers the health api endpoint.
|
||||
func bindHealthApi(app core.App, rg *echo.Group) {
|
||||
api := healthApi{app: app}
|
||||
|
||||
subGroup := rg.Group("/health")
|
||||
subGroup.GET("", api.healthCheck)
|
||||
}
|
||||
|
||||
type healthApi struct {
|
||||
app core.App
|
||||
}
|
||||
|
||||
// healthCheck returns a 200 OK response if the server is healthy.
|
||||
func (api *healthApi) healthCheck(c echo.Context) error {
|
||||
payload := map[string]any{
|
||||
"code": http.StatusOK,
|
||||
"message": "API is healthy.",
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, payload)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package apis_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
)
|
||||
|
||||
func TestHealthAPI(t *testing.T) {
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "health status returns 200",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/health",
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"code":200`,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
scenario.Test(t)
|
||||
}
|
||||
}
|
||||
+9
-10
@@ -7,12 +7,11 @@ import (
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/tools/rest"
|
||||
"github.com/pocketbase/pocketbase/tools/search"
|
||||
)
|
||||
|
||||
// BindLogsApi registers the request logs api endpoints.
|
||||
func BindLogsApi(app core.App, rg *echo.Group) {
|
||||
// bindLogsApi registers the request logs api endpoints.
|
||||
func bindLogsApi(app core.App, rg *echo.Group) {
|
||||
api := logsApi{app: app}
|
||||
|
||||
subGroup := rg.Group("/logs", RequireAdminAuth())
|
||||
@@ -28,7 +27,7 @@ type logsApi struct {
|
||||
var requestFilterFields = []string{
|
||||
"rowid", "id", "created", "updated",
|
||||
"url", "method", "status", "auth",
|
||||
"ip", "referer", "userAgent",
|
||||
"remoteIp", "userIp", "referer", "userAgent",
|
||||
}
|
||||
|
||||
func (api *logsApi) requestsList(c echo.Context) error {
|
||||
@@ -36,10 +35,10 @@ func (api *logsApi) requestsList(c echo.Context) error {
|
||||
|
||||
result, err := search.NewProvider(fieldResolver).
|
||||
Query(api.app.LogsDao().RequestQuery()).
|
||||
ParseAndExec(c.QueryString(), &[]*models.Request{})
|
||||
ParseAndExec(c.QueryParams().Encode(), &[]*models.Request{})
|
||||
|
||||
if err != nil {
|
||||
return rest.NewBadRequestError("", err)
|
||||
return NewBadRequestError("", err)
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, result)
|
||||
@@ -55,13 +54,13 @@ func (api *logsApi) requestsStats(c echo.Context) error {
|
||||
var err error
|
||||
expr, err = search.FilterData(filter).BuildExpr(fieldResolver)
|
||||
if err != nil {
|
||||
return rest.NewBadRequestError("Invalid filter format.", err)
|
||||
return NewBadRequestError("Invalid filter format.", err)
|
||||
}
|
||||
}
|
||||
|
||||
stats, err := api.app.LogsDao().RequestsStats(expr)
|
||||
if err != nil {
|
||||
return rest.NewBadRequestError("Failed to generate requests stats.", err)
|
||||
return NewBadRequestError("Failed to generate requests stats.", err)
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, stats)
|
||||
@@ -70,12 +69,12 @@ func (api *logsApi) requestsStats(c echo.Context) error {
|
||||
func (api *logsApi) requestView(c echo.Context) error {
|
||||
id := c.PathParam("id")
|
||||
if id == "" {
|
||||
return rest.NewNotFoundError("", nil)
|
||||
return NewNotFoundError("", nil)
|
||||
}
|
||||
|
||||
request, err := api.app.LogsDao().FindRequestById(id)
|
||||
if err != nil || request == nil {
|
||||
return rest.NewNotFoundError("", err)
|
||||
return NewNotFoundError("", err)
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, request)
|
||||
|
||||
+20
-20
@@ -18,11 +18,11 @@ func TestRequestsList(t *testing.T) {
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "authorized as user",
|
||||
Name: "authorized as auth record",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/logs/requests",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
|
||||
},
|
||||
ExpectedStatus: 401,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
@@ -32,9 +32,9 @@ func TestRequestsList(t *testing.T) {
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/logs/requests",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
BeforeFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
if err := tests.MockRequestLogsData(app); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -54,9 +54,9 @@ func TestRequestsList(t *testing.T) {
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/logs/requests?filter=status>200",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
BeforeFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
if err := tests.MockRequestLogsData(app); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -87,11 +87,11 @@ func TestRequestView(t *testing.T) {
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "authorized as user",
|
||||
Name: "authorized as auth record",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/logs/requests/873f2133-9f38-44fb-bf82-c8f53b310d91",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
|
||||
},
|
||||
ExpectedStatus: 401,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
@@ -101,9 +101,9 @@ func TestRequestView(t *testing.T) {
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/logs/requests/missing1-9f38-44fb-bf82-c8f53b310d91",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
BeforeFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
if err := tests.MockRequestLogsData(app); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -116,9 +116,9 @@ func TestRequestView(t *testing.T) {
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/logs/requests/873f2133-9f38-44fb-bf82-c8f53b310d91",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
BeforeFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
if err := tests.MockRequestLogsData(app); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -145,11 +145,11 @@ func TestRequestsStats(t *testing.T) {
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "authorized as user",
|
||||
Name: "authorized as auth record",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/logs/requests/stats",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
|
||||
},
|
||||
ExpectedStatus: 401,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
@@ -159,16 +159,16 @@ func TestRequestsStats(t *testing.T) {
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/logs/requests/stats",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
BeforeFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
if err := tests.MockRequestLogsData(app); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`[{"total":1,"date":"2022-05-01 10:00:00.000"},{"total":1,"date":"2022-05-02 10:00:00.000"}]`,
|
||||
`[{"total":1,"date":"2022-05-01 10:00:00.000Z"},{"total":1,"date":"2022-05-02 10:00:00.000Z"}]`,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -176,16 +176,16 @@ func TestRequestsStats(t *testing.T) {
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/logs/requests/stats?filter=status>200",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
BeforeFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
if err := tests.MockRequestLogsData(app); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`[{"total":1,"date":"2022-05-02 10:00:00.000"}]`,
|
||||
`[{"total":1,"date":"2022-05-02 10:00:00.000Z"}]`,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
+164
-78
@@ -3,6 +3,7 @@ package apis
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -10,30 +11,32 @@ import (
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/tools/rest"
|
||||
"github.com/pocketbase/pocketbase/tokens"
|
||||
"github.com/pocketbase/pocketbase/tools/list"
|
||||
"github.com/pocketbase/pocketbase/tools/routine"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
"github.com/spf13/cast"
|
||||
)
|
||||
|
||||
// Common request context keys used by the middlewares and api handlers.
|
||||
const (
|
||||
ContextUserKey string = "user"
|
||||
ContextAdminKey string = "admin"
|
||||
ContextAuthRecordKey string = "authRecord"
|
||||
ContextCollectionKey string = "collection"
|
||||
)
|
||||
|
||||
// RequireGuestOnly middleware requires a request to NOT have a valid
|
||||
// Authorization header set.
|
||||
// Authorization header.
|
||||
//
|
||||
// This middleware is the opposite of [apis.RequireAdminOrUserAuth()].
|
||||
// This middleware is the opposite of [apis.RequireAdminOrRecordAuth()].
|
||||
func RequireGuestOnly() echo.MiddlewareFunc {
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
err := rest.NewBadRequestError("The request can be accessed only by guests.", nil)
|
||||
err := NewBadRequestError("The request can be accessed only by guests.", nil)
|
||||
|
||||
user, _ := c.Get(ContextUserKey).(*models.User)
|
||||
if user != nil {
|
||||
record, _ := c.Get(ContextAuthRecordKey).(*models.Record)
|
||||
if record != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -47,14 +50,55 @@ func RequireGuestOnly() echo.MiddlewareFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// RequireUserAuth middleware requires a request to have
|
||||
// a valid user Authorization header set (aka. `Authorization: User ...`).
|
||||
func RequireUserAuth() echo.MiddlewareFunc {
|
||||
// RequireRecordAuth middleware requires a request to have
|
||||
// a valid record auth Authorization header.
|
||||
//
|
||||
// The auth record could be from any collection.
|
||||
//
|
||||
// You can further filter the allowed record auth collections by
|
||||
// specifying their names.
|
||||
//
|
||||
// Example:
|
||||
// apis.RequireRecordAuth()
|
||||
// Or:
|
||||
// apis.RequireRecordAuth("users", "supervisors")
|
||||
//
|
||||
// To restrict the auth record only to the loaded context collection,
|
||||
// use [apis.RequireSameContextRecordAuth()] instead.
|
||||
func RequireRecordAuth(optCollectionNames ...string) echo.MiddlewareFunc {
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
user, _ := c.Get(ContextUserKey).(*models.User)
|
||||
if user == nil {
|
||||
return rest.NewUnauthorizedError("The request requires valid user authorization token to be set.", nil)
|
||||
record, _ := c.Get(ContextAuthRecordKey).(*models.Record)
|
||||
if record == nil {
|
||||
return NewUnauthorizedError("The request requires valid record authorization token to be set.", nil)
|
||||
}
|
||||
|
||||
// check record collection name
|
||||
if len(optCollectionNames) > 0 && !list.ExistInSlice(record.Collection().Name, optCollectionNames) {
|
||||
return NewForbiddenError("The authorized record model is not allowed to perform this action.", nil)
|
||||
}
|
||||
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// RequireSameContextRecordAuth middleware requires a request to have
|
||||
// a valid record Authorization header.
|
||||
//
|
||||
// The auth record must be from the same collection already loaded in the context.
|
||||
func RequireSameContextRecordAuth() echo.MiddlewareFunc {
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
record, _ := c.Get(ContextAuthRecordKey).(*models.Record)
|
||||
if record == nil {
|
||||
return NewUnauthorizedError("The request requires valid record authorization token to be set.", nil)
|
||||
}
|
||||
|
||||
collection, _ := c.Get(ContextCollectionKey).(*models.Collection)
|
||||
if collection == nil || record.Collection().Id != collection.Id {
|
||||
return NewForbiddenError(fmt.Sprintf("The request requires auth record from %s collection.", record.Collection().Name), nil)
|
||||
}
|
||||
|
||||
return next(c)
|
||||
@@ -63,13 +107,13 @@ func RequireUserAuth() echo.MiddlewareFunc {
|
||||
}
|
||||
|
||||
// RequireAdminAuth middleware requires a request to have
|
||||
// a valid admin Authorization header set (aka. `Authorization: Admin ...`).
|
||||
// a valid admin Authorization header.
|
||||
func RequireAdminAuth() echo.MiddlewareFunc {
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
admin, _ := c.Get(ContextAdminKey).(*models.Admin)
|
||||
if admin == nil {
|
||||
return rest.NewUnauthorizedError("The request requires admin authorization token to be set.", nil)
|
||||
return NewUnauthorizedError("The request requires valid admin authorization token to be set.", nil)
|
||||
}
|
||||
|
||||
return next(c)
|
||||
@@ -78,14 +122,14 @@ func RequireAdminAuth() echo.MiddlewareFunc {
|
||||
}
|
||||
|
||||
// RequireAdminAuthOnlyIfAny middleware requires a request to have
|
||||
// a valid admin Authorization header set (aka. `Authorization: Admin ...`)
|
||||
// ONLY if the application has at least 1 existing Admin model.
|
||||
// a valid admin Authorization header ONLY if the application has
|
||||
// at least 1 existing Admin model.
|
||||
func RequireAdminAuthOnlyIfAny(app core.App) echo.MiddlewareFunc {
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
totalAdmins, err := app.Dao().TotalAdmins()
|
||||
if err != nil {
|
||||
return rest.NewBadRequestError("Failed to fetch admins info.", err)
|
||||
return NewBadRequestError("Failed to fetch admins info.", err)
|
||||
}
|
||||
|
||||
admin, _ := c.Get(ContextAdminKey).(*models.Admin)
|
||||
@@ -94,24 +138,29 @@ func RequireAdminAuthOnlyIfAny(app core.App) echo.MiddlewareFunc {
|
||||
return next(c)
|
||||
}
|
||||
|
||||
return rest.NewUnauthorizedError("The request requires admin authorization token to be set.", nil)
|
||||
return NewUnauthorizedError("The request requires valid admin authorization token to be set.", nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// RequireAdminOrUserAuth middleware requires a request to have
|
||||
// a valid admin or user Authorization header set
|
||||
// (aka. `Authorization: Admin ...` or `Authorization: User ...`).
|
||||
// RequireAdminOrRecordAuth middleware requires a request to have
|
||||
// a valid admin or record Authorization header set.
|
||||
//
|
||||
// You can further filter the allowed auth record collections by providing their names.
|
||||
//
|
||||
// This middleware is the opposite of [apis.RequireGuestOnly()].
|
||||
func RequireAdminOrUserAuth() echo.MiddlewareFunc {
|
||||
func RequireAdminOrRecordAuth(optCollectionNames ...string) echo.MiddlewareFunc {
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
admin, _ := c.Get(ContextAdminKey).(*models.Admin)
|
||||
user, _ := c.Get(ContextUserKey).(*models.User)
|
||||
record, _ := c.Get(ContextAuthRecordKey).(*models.Record)
|
||||
|
||||
if admin == nil && user == nil {
|
||||
return rest.NewUnauthorizedError("The request requires admin or user authorization token to be set.", nil)
|
||||
if admin == nil && record == nil {
|
||||
return NewUnauthorizedError("The request requires admin or record authorization token to be set.", nil)
|
||||
}
|
||||
|
||||
if record != nil && len(optCollectionNames) > 0 && !list.ExistInSlice(record.Collection().Name, optCollectionNames) {
|
||||
return NewForbiddenError("The authorized record model is not allowed to perform this action.", nil)
|
||||
}
|
||||
|
||||
return next(c)
|
||||
@@ -120,29 +169,33 @@ func RequireAdminOrUserAuth() echo.MiddlewareFunc {
|
||||
}
|
||||
|
||||
// RequireAdminOrOwnerAuth middleware requires a request to have
|
||||
// a valid admin or user owner Authorization header set
|
||||
// (aka. `Authorization: Admin ...` or `Authorization: User ...`).
|
||||
// a valid admin or auth record owner Authorization header set.
|
||||
//
|
||||
// This middleware is similar to [apis.RequireAdminOrUserAuth()] but
|
||||
// for the user token expects to have the same id as the path parameter
|
||||
// `ownerIdParam` (default to "id").
|
||||
// This middleware is similar to [apis.RequireAdminOrRecordAuth()] but
|
||||
// for the auth record token expects to have the same id as the path
|
||||
// parameter ownerIdParam (default to "id" if empty).
|
||||
func RequireAdminOrOwnerAuth(ownerIdParam string) echo.MiddlewareFunc {
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
admin, _ := c.Get(ContextAdminKey).(*models.Admin)
|
||||
if admin != nil {
|
||||
return next(c)
|
||||
}
|
||||
|
||||
record, _ := c.Get(ContextAuthRecordKey).(*models.Record)
|
||||
if record == nil {
|
||||
return NewUnauthorizedError("The request requires admin or record authorization token to be set.", nil)
|
||||
}
|
||||
|
||||
if ownerIdParam == "" {
|
||||
ownerIdParam = "id"
|
||||
}
|
||||
|
||||
ownerId := c.PathParam(ownerIdParam)
|
||||
admin, _ := c.Get(ContextAdminKey).(*models.Admin)
|
||||
loggedUser, _ := c.Get(ContextUserKey).(*models.User)
|
||||
|
||||
if admin == nil && loggedUser == nil {
|
||||
return rest.NewUnauthorizedError("The request requires admin or user authorization token to be set.", nil)
|
||||
}
|
||||
|
||||
if admin == nil && loggedUser.Id != ownerId {
|
||||
return rest.NewForbiddenError("You are not allowed to perform this request.", nil)
|
||||
// note: it is "safe" to compare only the record id since the auth
|
||||
// record ids are treated as unique across all auth collections
|
||||
if record.Id != ownerId {
|
||||
return NewForbiddenError("You are not allowed to perform this request.", nil)
|
||||
}
|
||||
|
||||
return next(c)
|
||||
@@ -151,32 +204,41 @@ func RequireAdminOrOwnerAuth(ownerIdParam string) echo.MiddlewareFunc {
|
||||
}
|
||||
|
||||
// LoadAuthContext middleware reads the Authorization request header
|
||||
// and loads the token related user or admin instance into the
|
||||
// and loads the token related record or admin instance into the
|
||||
// request's context.
|
||||
//
|
||||
// This middleware is expected to be registered by default for all routes.
|
||||
// This middleware is expected to be already registered by default for all routes.
|
||||
func LoadAuthContext(app core.App) echo.MiddlewareFunc {
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
token := c.Request().Header.Get("Authorization")
|
||||
if token == "" {
|
||||
return next(c)
|
||||
}
|
||||
|
||||
if token != "" {
|
||||
if strings.HasPrefix(token, "User ") {
|
||||
user, err := app.Dao().FindUserByToken(
|
||||
token[5:],
|
||||
app.Settings().UserAuthToken.Secret,
|
||||
)
|
||||
if err == nil && user != nil {
|
||||
c.Set(ContextUserKey, user)
|
||||
}
|
||||
} else if strings.HasPrefix(token, "Admin ") {
|
||||
admin, err := app.Dao().FindAdminByToken(
|
||||
token[6:],
|
||||
app.Settings().AdminAuthToken.Secret,
|
||||
)
|
||||
if err == nil && admin != nil {
|
||||
c.Set(ContextAdminKey, admin)
|
||||
}
|
||||
// the schema is not required and it is only for
|
||||
// compatibility with the defaults of some HTTP clients
|
||||
token = strings.TrimPrefix(token, "Bearer ")
|
||||
|
||||
claims, _ := security.ParseUnverifiedJWT(token)
|
||||
tokenType := cast.ToString(claims["type"])
|
||||
|
||||
switch tokenType {
|
||||
case tokens.TypeAdmin:
|
||||
admin, err := app.Dao().FindAdminByToken(
|
||||
token,
|
||||
app.Settings().AdminAuthToken.Secret,
|
||||
)
|
||||
if err == nil && admin != nil {
|
||||
c.Set(ContextAdminKey, admin)
|
||||
}
|
||||
case tokens.TypeAuthRecord:
|
||||
record, err := app.Dao().FindAuthRecordByToken(
|
||||
token,
|
||||
app.Settings().RecordAuthToken.Secret,
|
||||
)
|
||||
if err == nil && record != nil {
|
||||
c.Set(ContextAuthRecordKey, record)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,13 +249,19 @@ func LoadAuthContext(app core.App) echo.MiddlewareFunc {
|
||||
|
||||
// LoadCollectionContext middleware finds the collection with related
|
||||
// path identifier and loads it into the request context.
|
||||
func LoadCollectionContext(app core.App) echo.MiddlewareFunc {
|
||||
//
|
||||
// Set optCollectionTypes to further filter the found collection by its type.
|
||||
func LoadCollectionContext(app core.App, optCollectionTypes ...string) echo.MiddlewareFunc {
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
if param := c.PathParam("collection"); param != "" {
|
||||
collection, err := app.Dao().FindCollectionByNameOrId(param)
|
||||
if err != nil || collection == nil {
|
||||
return rest.NewNotFoundError("", err)
|
||||
return NewNotFoundError("", err)
|
||||
}
|
||||
|
||||
if len(optCollectionTypes) > 0 && !list.ExistInSlice(collection.Type, optCollectionTypes) {
|
||||
return NewBadRequestError("Invalid collection type.", nil)
|
||||
}
|
||||
|
||||
c.Set(ContextCollectionKey, collection)
|
||||
@@ -230,7 +298,7 @@ func ActivityLogger(app core.App) echo.MiddlewareFunc {
|
||||
status = v.Code
|
||||
meta["errorMessage"] = v.Message
|
||||
meta["errorDetails"] = fmt.Sprint(v.Internal)
|
||||
case *rest.ApiError:
|
||||
case *ApiError:
|
||||
status = v.Code
|
||||
meta["errorMessage"] = v.Message
|
||||
meta["errorDetails"] = fmt.Sprint(v.RawData())
|
||||
@@ -241,18 +309,21 @@ func ActivityLogger(app core.App) echo.MiddlewareFunc {
|
||||
}
|
||||
|
||||
requestAuth := models.RequestAuthGuest
|
||||
if c.Get(ContextUserKey) != nil {
|
||||
requestAuth = models.RequestAuthUser
|
||||
if c.Get(ContextAuthRecordKey) != nil {
|
||||
requestAuth = models.RequestAuthRecord
|
||||
} else if c.Get(ContextAdminKey) != nil {
|
||||
requestAuth = models.RequestAuthAdmin
|
||||
}
|
||||
|
||||
ip, _, _ := net.SplitHostPort(httpRequest.RemoteAddr)
|
||||
|
||||
model := &models.Request{
|
||||
Url: httpRequest.URL.RequestURI(),
|
||||
Method: strings.ToLower(httpRequest.Method),
|
||||
Status: status,
|
||||
Auth: requestAuth,
|
||||
Ip: httpRequest.RemoteAddr,
|
||||
UserIp: realUserIp(httpRequest, ip),
|
||||
RemoteIp: ip,
|
||||
Referer: httpRequest.Referer(),
|
||||
UserAgent: httpRequest.UserAgent(),
|
||||
Meta: meta,
|
||||
@@ -262,19 +333,8 @@ func ActivityLogger(app core.App) echo.MiddlewareFunc {
|
||||
model.RefreshUpdated()
|
||||
|
||||
routine.FireAndForget(func() {
|
||||
attempts := 1
|
||||
|
||||
BeginSave:
|
||||
logErr := app.LogsDao().SaveRequest(model)
|
||||
if logErr != nil {
|
||||
// try one more time after 10s in case of SQLITE_BUSY or "database is locked" error
|
||||
if attempts <= 2 {
|
||||
attempts++
|
||||
time.Sleep(10 * time.Second)
|
||||
goto BeginSave
|
||||
} else if app.IsDebug() {
|
||||
log.Println("Log save failed:", logErr)
|
||||
}
|
||||
if err := app.LogsDao().SaveRequest(model); err != nil && app.IsDebug() {
|
||||
log.Println("Log save failed:", err)
|
||||
}
|
||||
|
||||
// Delete old request logs
|
||||
@@ -297,3 +357,29 @@ func ActivityLogger(app core.App) echo.MiddlewareFunc {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Returns the "real" user IP from common proxy headers (or fallbackIp if none is found).
|
||||
//
|
||||
// The returned IP value shouldn't be trusted if not behind a trusted reverse proxy!
|
||||
func realUserIp(r *http.Request, fallbackIp string) string {
|
||||
if ip := r.Header.Get("CF-Connecting-IP"); ip != "" {
|
||||
return ip
|
||||
}
|
||||
|
||||
if ip := r.Header.Get("X-Real-IP"); ip != "" {
|
||||
return ip
|
||||
}
|
||||
|
||||
if ipsList := r.Header.Get("X-Forwarded-For"); ipsList != "" {
|
||||
ips := strings.Split(ipsList, ",")
|
||||
// extract the rightmost ip
|
||||
for i := len(ips) - 1; i >= 0; i-- {
|
||||
ip := strings.TrimSpace(ips[i])
|
||||
if ip != "" {
|
||||
return ip
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return fallbackIp
|
||||
}
|
||||
|
||||
+444
-68
@@ -12,13 +12,13 @@ import (
|
||||
func TestRequireGuestOnly(t *testing.T) {
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "valid user token",
|
||||
Name: "valid record token",
|
||||
Method: http.MethodGet,
|
||||
Url: "/my/test",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
|
||||
},
|
||||
BeforeFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
e.AddRoute(echo.Route{
|
||||
Method: http.MethodGet,
|
||||
Path: "/my/test",
|
||||
@@ -38,9 +38,9 @@ func TestRequireGuestOnly(t *testing.T) {
|
||||
Method: http.MethodGet,
|
||||
Url: "/my/test",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
BeforeFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
e.AddRoute(echo.Route{
|
||||
Method: http.MethodGet,
|
||||
Path: "/my/test",
|
||||
@@ -60,9 +60,9 @@ func TestRequireGuestOnly(t *testing.T) {
|
||||
Method: http.MethodGet,
|
||||
Url: "/my/test",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxNjQwOTkxNjYxfQ.HkAldxpbn0EybkMfFGQKEJUIYKE5UJA0AjcsrV7Q6Io",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoxNjQwOTkxNjYxfQ.HqvpCpM0RAk3Qu9PfCMuZsk_DKh9UYuzFLwXBMTZd1w",
|
||||
},
|
||||
BeforeFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
e.AddRoute(echo.Route{
|
||||
Method: http.MethodGet,
|
||||
Path: "/my/test",
|
||||
@@ -81,7 +81,7 @@ func TestRequireGuestOnly(t *testing.T) {
|
||||
Name: "guest",
|
||||
Method: http.MethodGet,
|
||||
Url: "/my/test",
|
||||
BeforeFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
e.AddRoute(echo.Route{
|
||||
Method: http.MethodGet,
|
||||
Path: "/my/test",
|
||||
@@ -103,13 +103,13 @@ func TestRequireGuestOnly(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireUserAuth(t *testing.T) {
|
||||
func TestRequireRecordAuth(t *testing.T) {
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "guest",
|
||||
Method: http.MethodGet,
|
||||
Url: "/my/test",
|
||||
BeforeFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
e.AddRoute(echo.Route{
|
||||
Method: http.MethodGet,
|
||||
Path: "/my/test",
|
||||
@@ -117,7 +117,7 @@ func TestRequireUserAuth(t *testing.T) {
|
||||
return c.String(200, "test123")
|
||||
},
|
||||
Middlewares: []echo.MiddlewareFunc{
|
||||
apis.RequireUserAuth(),
|
||||
apis.RequireRecordAuth(),
|
||||
},
|
||||
})
|
||||
},
|
||||
@@ -129,9 +129,9 @@ func TestRequireUserAuth(t *testing.T) {
|
||||
Method: http.MethodGet,
|
||||
Url: "/my/test",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxNjQwOTkxNjYxfQ.HkAldxpbn0EybkMfFGQKEJUIYKE5UJA0AjcsrV7Q6Io",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoxNjQwOTkxNjYxfQ.HqvpCpM0RAk3Qu9PfCMuZsk_DKh9UYuzFLwXBMTZd1w",
|
||||
},
|
||||
BeforeFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
e.AddRoute(echo.Route{
|
||||
Method: http.MethodGet,
|
||||
Path: "/my/test",
|
||||
@@ -139,7 +139,7 @@ func TestRequireUserAuth(t *testing.T) {
|
||||
return c.String(200, "test123")
|
||||
},
|
||||
Middlewares: []echo.MiddlewareFunc{
|
||||
apis.RequireUserAuth(),
|
||||
apis.RequireRecordAuth(),
|
||||
},
|
||||
})
|
||||
},
|
||||
@@ -151,9 +151,9 @@ func TestRequireUserAuth(t *testing.T) {
|
||||
Method: http.MethodGet,
|
||||
Url: "/my/test",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
BeforeFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
e.AddRoute(echo.Route{
|
||||
Method: http.MethodGet,
|
||||
Path: "/my/test",
|
||||
@@ -161,7 +161,7 @@ func TestRequireUserAuth(t *testing.T) {
|
||||
return c.String(200, "test123")
|
||||
},
|
||||
Middlewares: []echo.MiddlewareFunc{
|
||||
apis.RequireUserAuth(),
|
||||
apis.RequireRecordAuth(),
|
||||
},
|
||||
})
|
||||
},
|
||||
@@ -169,13 +169,13 @@ func TestRequireUserAuth(t *testing.T) {
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "valid user token",
|
||||
Name: "valid record token",
|
||||
Method: http.MethodGet,
|
||||
Url: "/my/test",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
|
||||
},
|
||||
BeforeFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
e.AddRoute(echo.Route{
|
||||
Method: http.MethodGet,
|
||||
Path: "/my/test",
|
||||
@@ -183,7 +183,167 @@ func TestRequireUserAuth(t *testing.T) {
|
||||
return c.String(200, "test123")
|
||||
},
|
||||
Middlewares: []echo.MiddlewareFunc{
|
||||
apis.RequireUserAuth(),
|
||||
apis.RequireRecordAuth(),
|
||||
},
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{"test123"},
|
||||
},
|
||||
{
|
||||
Name: "valid record token with collection not in the restricted list",
|
||||
Method: http.MethodGet,
|
||||
Url: "/my/test",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
e.AddRoute(echo.Route{
|
||||
Method: http.MethodGet,
|
||||
Path: "/my/test",
|
||||
Handler: func(c echo.Context) error {
|
||||
return c.String(200, "test123")
|
||||
},
|
||||
Middlewares: []echo.MiddlewareFunc{
|
||||
apis.RequireRecordAuth("demo1", "demo2"),
|
||||
},
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 403,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "valid record token with collection in the restricted list",
|
||||
Method: http.MethodGet,
|
||||
Url: "/my/test",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
e.AddRoute(echo.Route{
|
||||
Method: http.MethodGet,
|
||||
Path: "/my/test",
|
||||
Handler: func(c echo.Context) error {
|
||||
return c.String(200, "test123")
|
||||
},
|
||||
Middlewares: []echo.MiddlewareFunc{
|
||||
apis.RequireRecordAuth("demo1", "demo2", "users"),
|
||||
},
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{"test123"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
scenario.Test(t)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireSameContextRecordAuth(t *testing.T) {
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "guest",
|
||||
Method: http.MethodGet,
|
||||
Url: "/my/users/test",
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
e.AddRoute(echo.Route{
|
||||
Method: http.MethodGet,
|
||||
Path: "/my/:collection/test",
|
||||
Handler: func(c echo.Context) error {
|
||||
return c.String(200, "test123")
|
||||
},
|
||||
Middlewares: []echo.MiddlewareFunc{
|
||||
apis.RequireSameContextRecordAuth(),
|
||||
},
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 401,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "expired/invalid token",
|
||||
Method: http.MethodGet,
|
||||
Url: "/my/users/test",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoxNjQwOTkxNjYxfQ.HqvpCpM0RAk3Qu9PfCMuZsk_DKh9UYuzFLwXBMTZd1w",
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
e.AddRoute(echo.Route{
|
||||
Method: http.MethodGet,
|
||||
Path: "/my/:collection/test",
|
||||
Handler: func(c echo.Context) error {
|
||||
return c.String(200, "test123")
|
||||
},
|
||||
Middlewares: []echo.MiddlewareFunc{
|
||||
apis.RequireSameContextRecordAuth(),
|
||||
},
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 401,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "valid admin token",
|
||||
Method: http.MethodGet,
|
||||
Url: "/my/users/test",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
e.AddRoute(echo.Route{
|
||||
Method: http.MethodGet,
|
||||
Path: "/my/:collection/test",
|
||||
Handler: func(c echo.Context) error {
|
||||
return c.String(200, "test123")
|
||||
},
|
||||
Middlewares: []echo.MiddlewareFunc{
|
||||
apis.RequireSameContextRecordAuth(),
|
||||
},
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 401,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "valid record token but from different collection",
|
||||
Method: http.MethodGet,
|
||||
Url: "/my/users/test",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6ImdrMzkwcWVnczR5NDd3biIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoidjg1MXE0cjc5MHJoa25sIiwiZXhwIjoyMjA4OTg1MjYxfQ.q34IWXrRWsjLvbbVNRfAs_J4SoTHloNBfdGEiLmy-D8",
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
e.AddRoute(echo.Route{
|
||||
Method: http.MethodGet,
|
||||
Path: "/my/:collection/test",
|
||||
Handler: func(c echo.Context) error {
|
||||
return c.String(200, "test123")
|
||||
},
|
||||
Middlewares: []echo.MiddlewareFunc{
|
||||
apis.RequireSameContextRecordAuth(),
|
||||
},
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 403,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "valid record token",
|
||||
Method: http.MethodGet,
|
||||
Url: "/my/test",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
e.AddRoute(echo.Route{
|
||||
Method: http.MethodGet,
|
||||
Path: "/my/test",
|
||||
Handler: func(c echo.Context) error {
|
||||
return c.String(200, "test123")
|
||||
},
|
||||
Middlewares: []echo.MiddlewareFunc{
|
||||
apis.RequireRecordAuth(),
|
||||
},
|
||||
})
|
||||
},
|
||||
@@ -203,7 +363,7 @@ func TestRequireAdminAuth(t *testing.T) {
|
||||
Name: "guest",
|
||||
Method: http.MethodGet,
|
||||
Url: "/my/test",
|
||||
BeforeFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
e.AddRoute(echo.Route{
|
||||
Method: http.MethodGet,
|
||||
Path: "/my/test",
|
||||
@@ -223,9 +383,9 @@ func TestRequireAdminAuth(t *testing.T) {
|
||||
Method: http.MethodGet,
|
||||
Url: "/my/test",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTY0MTAxMzIwMH0.Gp_1b5WVhqjj2o3nJhNUlJmpdiwFLXN72LbMP-26gjA",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTY0MTAxMzIwMH0.Gp_1b5WVhqjj2o3nJhNUlJmpdiwFLXN72LbMP-26gjA",
|
||||
},
|
||||
BeforeFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
e.AddRoute(echo.Route{
|
||||
Method: http.MethodGet,
|
||||
Path: "/my/test",
|
||||
@@ -241,13 +401,13 @@ func TestRequireAdminAuth(t *testing.T) {
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "valid user token",
|
||||
Name: "valid record token",
|
||||
Method: http.MethodGet,
|
||||
Url: "/my/test",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
|
||||
},
|
||||
BeforeFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
e.AddRoute(echo.Route{
|
||||
Method: http.MethodGet,
|
||||
Path: "/my/test",
|
||||
@@ -267,9 +427,9 @@ func TestRequireAdminAuth(t *testing.T) {
|
||||
Method: http.MethodGet,
|
||||
Url: "/my/test",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
BeforeFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
e.AddRoute(echo.Route{
|
||||
Method: http.MethodGet,
|
||||
Path: "/my/test",
|
||||
@@ -297,7 +457,7 @@ func TestRequireAdminAuthOnlyIfAny(t *testing.T) {
|
||||
Name: "guest (while having at least 1 existing admin)",
|
||||
Method: http.MethodGet,
|
||||
Url: "/my/test",
|
||||
BeforeFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
e.AddRoute(echo.Route{
|
||||
Method: http.MethodGet,
|
||||
Path: "/my/test",
|
||||
@@ -316,7 +476,7 @@ func TestRequireAdminAuthOnlyIfAny(t *testing.T) {
|
||||
Name: "guest (while having 0 existing admins)",
|
||||
Method: http.MethodGet,
|
||||
Url: "/my/test",
|
||||
BeforeFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
// delete all admins
|
||||
_, err := app.Dao().DB().NewQuery("DELETE FROM {{_admins}}").Execute()
|
||||
if err != nil {
|
||||
@@ -342,9 +502,9 @@ func TestRequireAdminAuthOnlyIfAny(t *testing.T) {
|
||||
Method: http.MethodGet,
|
||||
Url: "/my/test",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTY0MTAxMzIwMH0.Gp_1b5WVhqjj2o3nJhNUlJmpdiwFLXN72LbMP-26gjA",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTY0MTAxMzIwMH0.Gp_1b5WVhqjj2o3nJhNUlJmpdiwFLXN72LbMP-26gjA",
|
||||
},
|
||||
BeforeFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
e.AddRoute(echo.Route{
|
||||
Method: http.MethodGet,
|
||||
Path: "/my/test",
|
||||
@@ -360,13 +520,13 @@ func TestRequireAdminAuthOnlyIfAny(t *testing.T) {
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "valid user token",
|
||||
Name: "valid record token",
|
||||
Method: http.MethodGet,
|
||||
Url: "/my/test",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
|
||||
},
|
||||
BeforeFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
e.AddRoute(echo.Route{
|
||||
Method: http.MethodGet,
|
||||
Path: "/my/test",
|
||||
@@ -386,9 +546,9 @@ func TestRequireAdminAuthOnlyIfAny(t *testing.T) {
|
||||
Method: http.MethodGet,
|
||||
Url: "/my/test",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
BeforeFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
e.AddRoute(echo.Route{
|
||||
Method: http.MethodGet,
|
||||
Path: "/my/test",
|
||||
@@ -410,13 +570,13 @@ func TestRequireAdminAuthOnlyIfAny(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireAdminOrUserAuth(t *testing.T) {
|
||||
func TestRequireAdminOrRecordAuth(t *testing.T) {
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "guest",
|
||||
Method: http.MethodGet,
|
||||
Url: "/my/test",
|
||||
BeforeFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
e.AddRoute(echo.Route{
|
||||
Method: http.MethodGet,
|
||||
Path: "/my/test",
|
||||
@@ -424,7 +584,7 @@ func TestRequireAdminOrUserAuth(t *testing.T) {
|
||||
return c.String(200, "test123")
|
||||
},
|
||||
Middlewares: []echo.MiddlewareFunc{
|
||||
apis.RequireAdminOrUserAuth(),
|
||||
apis.RequireAdminOrRecordAuth(),
|
||||
},
|
||||
})
|
||||
},
|
||||
@@ -436,9 +596,9 @@ func TestRequireAdminOrUserAuth(t *testing.T) {
|
||||
Method: http.MethodGet,
|
||||
Url: "/my/test",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTY0MTAxMzIwMH0.Gp_1b5WVhqjj2o3nJhNUlJmpdiwFLXN72LbMP-26gjA",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTY0MTAxMzIwMH0.Gp_1b5WVhqjj2o3nJhNUlJmpdiwFLXN72LbMP-26gjA",
|
||||
},
|
||||
BeforeFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
e.AddRoute(echo.Route{
|
||||
Method: http.MethodGet,
|
||||
Path: "/my/test",
|
||||
@@ -446,7 +606,7 @@ func TestRequireAdminOrUserAuth(t *testing.T) {
|
||||
return c.String(200, "test123")
|
||||
},
|
||||
Middlewares: []echo.MiddlewareFunc{
|
||||
apis.RequireAdminOrUserAuth(),
|
||||
apis.RequireAdminOrRecordAuth(),
|
||||
},
|
||||
})
|
||||
},
|
||||
@@ -454,13 +614,13 @@ func TestRequireAdminOrUserAuth(t *testing.T) {
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "valid user token",
|
||||
Name: "valid record token",
|
||||
Method: http.MethodGet,
|
||||
Url: "/my/test",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
|
||||
},
|
||||
BeforeFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
e.AddRoute(echo.Route{
|
||||
Method: http.MethodGet,
|
||||
Path: "/my/test",
|
||||
@@ -468,7 +628,51 @@ func TestRequireAdminOrUserAuth(t *testing.T) {
|
||||
return c.String(200, "test123")
|
||||
},
|
||||
Middlewares: []echo.MiddlewareFunc{
|
||||
apis.RequireAdminOrUserAuth(),
|
||||
apis.RequireAdminOrRecordAuth(),
|
||||
},
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{"test123"},
|
||||
},
|
||||
{
|
||||
Name: "valid record token with collection not in the restricted list",
|
||||
Method: http.MethodGet,
|
||||
Url: "/my/test",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
e.AddRoute(echo.Route{
|
||||
Method: http.MethodGet,
|
||||
Path: "/my/test",
|
||||
Handler: func(c echo.Context) error {
|
||||
return c.String(200, "test123")
|
||||
},
|
||||
Middlewares: []echo.MiddlewareFunc{
|
||||
apis.RequireAdminOrRecordAuth("demo1", "demo2", "clients"),
|
||||
},
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 403,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "valid record token with collection in the restricted list",
|
||||
Method: http.MethodGet,
|
||||
Url: "/my/test",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
e.AddRoute(echo.Route{
|
||||
Method: http.MethodGet,
|
||||
Path: "/my/test",
|
||||
Handler: func(c echo.Context) error {
|
||||
return c.String(200, "test123")
|
||||
},
|
||||
Middlewares: []echo.MiddlewareFunc{
|
||||
apis.RequireAdminOrRecordAuth("demo1", "demo2", "users"),
|
||||
},
|
||||
})
|
||||
},
|
||||
@@ -480,9 +684,9 @@ func TestRequireAdminOrUserAuth(t *testing.T) {
|
||||
Method: http.MethodGet,
|
||||
Url: "/my/test",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
BeforeFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
e.AddRoute(echo.Route{
|
||||
Method: http.MethodGet,
|
||||
Path: "/my/test",
|
||||
@@ -490,7 +694,29 @@ func TestRequireAdminOrUserAuth(t *testing.T) {
|
||||
return c.String(200, "test123")
|
||||
},
|
||||
Middlewares: []echo.MiddlewareFunc{
|
||||
apis.RequireAdminOrUserAuth(),
|
||||
apis.RequireAdminOrRecordAuth(),
|
||||
},
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{"test123"},
|
||||
},
|
||||
{
|
||||
Name: "valid admin token + restricted collections list (should be ignored)",
|
||||
Method: http.MethodGet,
|
||||
Url: "/my/test",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
e.AddRoute(echo.Route{
|
||||
Method: http.MethodGet,
|
||||
Path: "/my/test",
|
||||
Handler: func(c echo.Context) error {
|
||||
return c.String(200, "test123")
|
||||
},
|
||||
Middlewares: []echo.MiddlewareFunc{
|
||||
apis.RequireAdminOrRecordAuth("demo1", "demo2"),
|
||||
},
|
||||
})
|
||||
},
|
||||
@@ -509,8 +735,8 @@ func TestRequireAdminOrOwnerAuth(t *testing.T) {
|
||||
{
|
||||
Name: "guest",
|
||||
Method: http.MethodGet,
|
||||
Url: "/my/test/4d0197cc-2b4a-3f83-a26b-d77bc8423d3c",
|
||||
BeforeFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
Url: "/my/test/4q1xlclmfloku33",
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
e.AddRoute(echo.Route{
|
||||
Method: http.MethodGet,
|
||||
Path: "/my/test/:id",
|
||||
@@ -528,11 +754,11 @@ func TestRequireAdminOrOwnerAuth(t *testing.T) {
|
||||
{
|
||||
Name: "expired/invalid token",
|
||||
Method: http.MethodGet,
|
||||
Url: "/my/test/4d0197cc-2b4a-3f83-a26b-d77bc8423d3c",
|
||||
Url: "/my/test/4q1xlclmfloku33",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxNjQwOTkxNjYxfQ.HkAldxpbn0EybkMfFGQKEJUIYKE5UJA0AjcsrV7Q6Io",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoxNjQwOTkxNjYxfQ.HqvpCpM0RAk3Qu9PfCMuZsk_DKh9UYuzFLwXBMTZd1w",
|
||||
},
|
||||
BeforeFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
e.AddRoute(echo.Route{
|
||||
Method: http.MethodGet,
|
||||
Path: "/my/test/:id",
|
||||
@@ -548,14 +774,13 @@ func TestRequireAdminOrOwnerAuth(t *testing.T) {
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "valid user token (different user)",
|
||||
Name: "valid record token (different user)",
|
||||
Method: http.MethodGet,
|
||||
Url: "/my/test/4d0197cc-2b4a-3f83-a26b-d77bc8423d3c",
|
||||
Url: "/my/test/4q1xlclmfloku33",
|
||||
RequestHeaders: map[string]string{
|
||||
// test3@example.com
|
||||
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0eXBlIjoidXNlciIsImVtYWlsIjoidGVzdDNAZXhhbXBsZS5jb20iLCJpZCI6Ijk3Y2MzZDNkLTZiYTItMzgzZi1iNDJhLTdiYzg0ZDI3NDEwYyIsImV4cCI6MTg5MzUxNTU3Nn0.Q965uvlTxxOsZbACXSgJQNXykYK0TKZ87nyPzemvN4E",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6ImJnczgyMG4zNjF2ajFxZCIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.tW4NZWZ0mHBgvSZsQ0OOQhWajpUNFPCvNrOF9aCZLZs",
|
||||
},
|
||||
BeforeFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
e.AddRoute(echo.Route{
|
||||
Method: http.MethodGet,
|
||||
Path: "/my/test/:id",
|
||||
@@ -571,13 +796,35 @@ func TestRequireAdminOrOwnerAuth(t *testing.T) {
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "valid user token (owner)",
|
||||
Name: "valid record token (different collection)",
|
||||
Method: http.MethodGet,
|
||||
Url: "/my/test/4d0197cc-2b4a-3f83-a26b-d77bc8423d3c",
|
||||
Url: "/my/test/4q1xlclmfloku33",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6ImdrMzkwcWVnczR5NDd3biIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoidjg1MXE0cjc5MHJoa25sIiwiZXhwIjoyMjA4OTg1MjYxfQ.q34IWXrRWsjLvbbVNRfAs_J4SoTHloNBfdGEiLmy-D8",
|
||||
},
|
||||
BeforeFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
e.AddRoute(echo.Route{
|
||||
Method: http.MethodGet,
|
||||
Path: "/my/test/:id",
|
||||
Handler: func(c echo.Context) error {
|
||||
return c.String(200, "test123")
|
||||
},
|
||||
Middlewares: []echo.MiddlewareFunc{
|
||||
apis.RequireAdminOrOwnerAuth(""),
|
||||
},
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 403,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "valid record token (owner)",
|
||||
Method: http.MethodGet,
|
||||
Url: "/my/test/4q1xlclmfloku33",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
e.AddRoute(echo.Route{
|
||||
Method: http.MethodGet,
|
||||
Path: "/my/test/:id",
|
||||
@@ -595,11 +842,11 @@ func TestRequireAdminOrOwnerAuth(t *testing.T) {
|
||||
{
|
||||
Name: "valid admin token",
|
||||
Method: http.MethodGet,
|
||||
Url: "/my/test/2b4a97cc-3f83-4d01-a26b-3d77bc842d3c",
|
||||
Url: "/my/test/4q1xlclmfloku33",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
BeforeFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
e.AddRoute(echo.Route{
|
||||
Method: http.MethodGet,
|
||||
Path: "/my/test/:custom",
|
||||
@@ -620,3 +867,132 @@ func TestRequireAdminOrOwnerAuth(t *testing.T) {
|
||||
scenario.Test(t)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadCollectionContext(t *testing.T) {
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "missing collection",
|
||||
Method: http.MethodGet,
|
||||
Url: "/my/missing",
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
e.AddRoute(echo.Route{
|
||||
Method: http.MethodGet,
|
||||
Path: "/my/:collection",
|
||||
Handler: func(c echo.Context) error {
|
||||
return c.String(200, "test123")
|
||||
},
|
||||
Middlewares: []echo.MiddlewareFunc{
|
||||
apis.LoadCollectionContext(app),
|
||||
},
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 404,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "guest",
|
||||
Method: http.MethodGet,
|
||||
Url: "/my/demo1",
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
e.AddRoute(echo.Route{
|
||||
Method: http.MethodGet,
|
||||
Path: "/my/:collection",
|
||||
Handler: func(c echo.Context) error {
|
||||
return c.String(200, "test123")
|
||||
},
|
||||
Middlewares: []echo.MiddlewareFunc{
|
||||
apis.LoadCollectionContext(app),
|
||||
},
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{"test123"},
|
||||
},
|
||||
{
|
||||
Name: "valid record token",
|
||||
Method: http.MethodGet,
|
||||
Url: "/my/demo1",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
e.AddRoute(echo.Route{
|
||||
Method: http.MethodGet,
|
||||
Path: "/my/:collection",
|
||||
Handler: func(c echo.Context) error {
|
||||
return c.String(200, "test123")
|
||||
},
|
||||
Middlewares: []echo.MiddlewareFunc{
|
||||
apis.LoadCollectionContext(app),
|
||||
},
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{"test123"},
|
||||
},
|
||||
{
|
||||
Name: "valid admin token",
|
||||
Method: http.MethodGet,
|
||||
Url: "/my/demo1",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
e.AddRoute(echo.Route{
|
||||
Method: http.MethodGet,
|
||||
Path: "/my/:collection",
|
||||
Handler: func(c echo.Context) error {
|
||||
return c.String(200, "test123")
|
||||
},
|
||||
Middlewares: []echo.MiddlewareFunc{
|
||||
apis.LoadCollectionContext(app),
|
||||
},
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{"test123"},
|
||||
},
|
||||
{
|
||||
Name: "mismatched type",
|
||||
Method: http.MethodGet,
|
||||
Url: "/my/demo1",
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
e.AddRoute(echo.Route{
|
||||
Method: http.MethodGet,
|
||||
Path: "/my/:collection",
|
||||
Handler: func(c echo.Context) error {
|
||||
return c.String(200, "test123")
|
||||
},
|
||||
Middlewares: []echo.MiddlewareFunc{
|
||||
apis.LoadCollectionContext(app, "auth"),
|
||||
},
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "matched type",
|
||||
Method: http.MethodGet,
|
||||
Url: "/my/users",
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
e.AddRoute(echo.Route{
|
||||
Method: http.MethodGet,
|
||||
Path: "/my/:collection",
|
||||
Handler: func(c echo.Context) error {
|
||||
return c.String(200, "test123")
|
||||
},
|
||||
Middlewares: []echo.MiddlewareFunc{
|
||||
apis.LoadCollectionContext(app, "auth"),
|
||||
},
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{"test123"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
scenario.Test(t)
|
||||
}
|
||||
}
|
||||
|
||||
+175
-99
@@ -15,13 +15,13 @@ import (
|
||||
"github.com/pocketbase/pocketbase/forms"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/resolvers"
|
||||
"github.com/pocketbase/pocketbase/tools/rest"
|
||||
"github.com/pocketbase/pocketbase/tools/routine"
|
||||
"github.com/pocketbase/pocketbase/tools/search"
|
||||
"github.com/pocketbase/pocketbase/tools/subscriptions"
|
||||
)
|
||||
|
||||
// BindRealtimeApi registers the realtime api endpoints.
|
||||
func BindRealtimeApi(app core.App, rg *echo.Group) {
|
||||
// bindRealtimeApi registers the realtime api endpoints.
|
||||
func bindRealtimeApi(app core.App, rg *echo.Group) {
|
||||
api := realtimeApi{app: app}
|
||||
|
||||
subGroup := rg.Group("/realtime", ActivityLogger(app))
|
||||
@@ -43,30 +43,65 @@ func (api *realtimeApi) connect(c echo.Context) error {
|
||||
// register new subscription client
|
||||
client := subscriptions.NewDefaultClient()
|
||||
api.app.SubscriptionsBroker().Register(client)
|
||||
defer api.app.SubscriptionsBroker().Unregister(client.Id())
|
||||
defer func() {
|
||||
disconnectEvent := &core.RealtimeDisconnectEvent{
|
||||
HttpContext: c,
|
||||
Client: client,
|
||||
}
|
||||
|
||||
if err := api.app.OnRealtimeDisconnectRequest().Trigger(disconnectEvent); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
|
||||
api.app.SubscriptionsBroker().Unregister(client.Id())
|
||||
}()
|
||||
|
||||
c.Response().Header().Set("Content-Type", "text/event-stream; charset=UTF-8")
|
||||
c.Response().Header().Set("Cache-Control", "no-store")
|
||||
c.Response().Header().Set("Connection", "keep-alive")
|
||||
// https://github.com/pocketbase/pocketbase/discussions/480#discussioncomment-3657640
|
||||
// https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_buffering
|
||||
c.Response().Header().Set("X-Accel-Buffering", "no")
|
||||
|
||||
event := &core.RealtimeConnectEvent{
|
||||
connectEvent := &core.RealtimeConnectEvent{
|
||||
HttpContext: c,
|
||||
Client: client,
|
||||
}
|
||||
|
||||
if err := api.app.OnRealtimeConnectRequest().Trigger(event); err != nil {
|
||||
if err := api.app.OnRealtimeConnectRequest().Trigger(connectEvent); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if api.app.IsDebug() {
|
||||
log.Printf("Realtime connection establisehd: %s\n", client.Id())
|
||||
log.Printf("Realtime connection established: %s\n", client.Id())
|
||||
}
|
||||
|
||||
// signalize established connection (aka. fire "connect" message)
|
||||
fmt.Fprint(c.Response(), "id:"+client.Id()+"\n")
|
||||
fmt.Fprint(c.Response(), "event:PB_CONNECT\n")
|
||||
fmt.Fprint(c.Response(), "data:{\"clientId\":\""+client.Id()+"\"}\n\n")
|
||||
c.Response().Flush()
|
||||
connectMsgEvent := &core.RealtimeMessageEvent{
|
||||
HttpContext: c,
|
||||
Client: client,
|
||||
Message: &subscriptions.Message{
|
||||
Name: "PB_CONNECT",
|
||||
Data: `{"clientId":"` + client.Id() + `"}`,
|
||||
},
|
||||
}
|
||||
connectMsgErr := api.app.OnRealtimeBeforeMessageSend().Trigger(connectMsgEvent, func(e *core.RealtimeMessageEvent) error {
|
||||
w := e.HttpContext.Response()
|
||||
fmt.Fprint(w, "id:"+client.Id()+"\n")
|
||||
fmt.Fprint(w, "event:"+e.Message.Name+"\n")
|
||||
fmt.Fprint(w, "data:"+e.Message.Data+"\n\n")
|
||||
w.Flush()
|
||||
return nil
|
||||
})
|
||||
if connectMsgErr != nil {
|
||||
if api.app.IsDebug() {
|
||||
log.Println("Realtime connection closed (failed to deliver PB_CONNECT):", client.Id(), connectMsgErr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := api.app.OnRealtimeAfterMessageSend().Trigger(connectMsgEvent); err != nil && api.app.IsDebug() {
|
||||
log.Println("OnRealtimeAfterMessageSend PB_CONNECT error:", err)
|
||||
}
|
||||
|
||||
// start an idle timer to keep track of inactive/forgotten connections
|
||||
idleDuration := 5 * time.Minute
|
||||
@@ -86,11 +121,29 @@ func (api *realtimeApi) connect(c echo.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
w := c.Response()
|
||||
fmt.Fprint(w, "id:"+client.Id()+"\n")
|
||||
fmt.Fprint(w, "event:"+msg.Name+"\n")
|
||||
fmt.Fprint(w, "data:"+msg.Data+"\n\n")
|
||||
w.Flush()
|
||||
msgEvent := &core.RealtimeMessageEvent{
|
||||
HttpContext: c,
|
||||
Client: client,
|
||||
Message: &msg,
|
||||
}
|
||||
msgErr := api.app.OnRealtimeBeforeMessageSend().Trigger(msgEvent, func(e *core.RealtimeMessageEvent) error {
|
||||
w := e.HttpContext.Response()
|
||||
fmt.Fprint(w, "id:"+e.Client.Id()+"\n")
|
||||
fmt.Fprint(w, "event:"+e.Message.Name+"\n")
|
||||
fmt.Fprint(w, "data:"+e.Message.Data+"\n\n")
|
||||
w.Flush()
|
||||
return nil
|
||||
})
|
||||
if msgErr != nil {
|
||||
if api.app.IsDebug() {
|
||||
log.Println("Realtime connection closed (failed to deliver message):", client.Id(), msgErr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := api.app.OnRealtimeAfterMessageSend().Trigger(msgEvent); err != nil && api.app.IsDebug() {
|
||||
log.Println("OnRealtimeAfterMessageSend error:", err)
|
||||
}
|
||||
|
||||
idleTimer.Stop()
|
||||
idleTimer.Reset(idleDuration)
|
||||
@@ -110,25 +163,25 @@ func (api *realtimeApi) setSubscriptions(c echo.Context) error {
|
||||
|
||||
// read request data
|
||||
if err := c.Bind(form); err != nil {
|
||||
return rest.NewBadRequestError("", err)
|
||||
return NewBadRequestError("", err)
|
||||
}
|
||||
|
||||
// validate request data
|
||||
if err := form.Validate(); err != nil {
|
||||
return rest.NewBadRequestError("", err)
|
||||
return NewBadRequestError("", err)
|
||||
}
|
||||
|
||||
// find subscription client
|
||||
client, err := api.app.SubscriptionsBroker().ClientById(form.ClientId)
|
||||
if err != nil {
|
||||
return rest.NewNotFoundError("Missing or invalid client id.", err)
|
||||
return NewNotFoundError("Missing or invalid client id.", err)
|
||||
}
|
||||
|
||||
// check if the previous request was authorized
|
||||
oldAuthId := extractAuthIdFromGetter(client)
|
||||
newAuthId := extractAuthIdFromGetter(c)
|
||||
if oldAuthId != "" && oldAuthId != newAuthId {
|
||||
return rest.NewForbiddenError("The current and the previous request authorization don't match.", nil)
|
||||
return NewForbiddenError("The current and the previous request authorization don't match.", nil)
|
||||
}
|
||||
|
||||
event := &core.RealtimeSubscribeEvent{
|
||||
@@ -140,7 +193,7 @@ func (api *realtimeApi) setSubscriptions(c echo.Context) error {
|
||||
handlerErr := api.app.OnRealtimeBeforeSubscribeRequest().Trigger(event, func(e *core.RealtimeSubscribeEvent) error {
|
||||
// update auth state
|
||||
e.Client.Set(ContextAdminKey, e.HttpContext.Get(ContextAdminKey))
|
||||
e.Client.Set(ContextUserKey, e.HttpContext.Get(ContextUserKey))
|
||||
e.Client.Set(ContextAuthRecordKey, e.HttpContext.Get(ContextAuthRecordKey))
|
||||
|
||||
// unsubscribe from any previous existing subscriptions
|
||||
e.Client.Unsubscribe()
|
||||
@@ -158,75 +211,80 @@ func (api *realtimeApi) setSubscriptions(c echo.Context) error {
|
||||
return handlerErr
|
||||
}
|
||||
|
||||
// updateClientsAuthModel updates the existing clients auth model with the new one (matched by ID).
|
||||
func (api *realtimeApi) updateClientsAuthModel(contextKey string, newModel models.Model) error {
|
||||
for _, client := range api.app.SubscriptionsBroker().Clients() {
|
||||
clientModel, _ := client.Get(contextKey).(models.Model)
|
||||
if clientModel != nil && clientModel.GetId() == newModel.GetId() {
|
||||
client.Set(contextKey, newModel)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// unregisterClientsByAuthModel unregister all clients that has the provided auth model.
|
||||
func (api *realtimeApi) unregisterClientsByAuthModel(contextKey string, model models.Model) error {
|
||||
for _, client := range api.app.SubscriptionsBroker().Clients() {
|
||||
clientModel, _ := client.Get(contextKey).(models.Model)
|
||||
if clientModel != nil && clientModel.GetId() == model.GetId() {
|
||||
api.app.SubscriptionsBroker().Unregister(client.Id())
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (api *realtimeApi) bindEvents() {
|
||||
userTable := (&models.User{}).TableName()
|
||||
adminTable := (&models.Admin{}).TableName()
|
||||
|
||||
// update user/admin auth state
|
||||
api.app.OnModelAfterUpdate().Add(func(e *core.ModelEvent) error {
|
||||
modelTable := e.Model.TableName()
|
||||
|
||||
var contextKey string
|
||||
switch modelTable {
|
||||
case userTable:
|
||||
contextKey = ContextUserKey
|
||||
case adminTable:
|
||||
contextKey = ContextAdminKey
|
||||
default:
|
||||
return nil
|
||||
// update the clients that has admin or auth record association
|
||||
api.app.OnModelAfterUpdate().PreAdd(func(e *core.ModelEvent) error {
|
||||
if record, ok := e.Model.(*models.Record); ok && record != nil && record.Collection().IsAuth() {
|
||||
return api.updateClientsAuthModel(ContextAuthRecordKey, record)
|
||||
}
|
||||
|
||||
for _, client := range api.app.SubscriptionsBroker().Clients() {
|
||||
model, _ := client.Get(contextKey).(models.Model)
|
||||
if model != nil && model.GetId() == e.Model.GetId() {
|
||||
client.Set(contextKey, e.Model)
|
||||
if admin, ok := e.Model.(*models.Admin); ok && admin != nil {
|
||||
return api.updateClientsAuthModel(ContextAdminKey, admin)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
// remove the client(s) associated to the deleted admin or auth record
|
||||
api.app.OnModelAfterDelete().PreAdd(func(e *core.ModelEvent) error {
|
||||
if record, ok := e.Model.(*models.Record); ok && record != nil && record.Collection().IsAuth() {
|
||||
return api.unregisterClientsByAuthModel(ContextAuthRecordKey, record)
|
||||
}
|
||||
|
||||
if admin, ok := e.Model.(*models.Admin); ok && admin != nil {
|
||||
return api.unregisterClientsByAuthModel(ContextAdminKey, admin)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
api.app.OnModelAfterCreate().PreAdd(func(e *core.ModelEvent) error {
|
||||
if record, ok := e.Model.(*models.Record); ok {
|
||||
if err := api.broadcastRecord("create", record); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
// remove user/admin client(s)
|
||||
api.app.OnModelAfterDelete().Add(func(e *core.ModelEvent) error {
|
||||
modelTable := e.Model.TableName()
|
||||
|
||||
var contextKey string
|
||||
switch modelTable {
|
||||
case userTable:
|
||||
contextKey = ContextUserKey
|
||||
case adminTable:
|
||||
contextKey = ContextAdminKey
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, client := range api.app.SubscriptionsBroker().Clients() {
|
||||
model, _ := client.Get(contextKey).(models.Model)
|
||||
if model != nil && model.GetId() == e.Model.GetId() {
|
||||
api.app.SubscriptionsBroker().Unregister(client.Id())
|
||||
api.app.OnModelAfterUpdate().PreAdd(func(e *core.ModelEvent) error {
|
||||
if record, ok := e.Model.(*models.Record); ok {
|
||||
if err := api.broadcastRecord("update", record); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
api.app.OnModelAfterCreate().Add(func(e *core.ModelEvent) error {
|
||||
if record, ok := e.Model.(*models.Record); ok {
|
||||
api.broadcastRecord("create", record)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
api.app.OnModelAfterUpdate().Add(func(e *core.ModelEvent) error {
|
||||
if record, ok := e.Model.(*models.Record); ok {
|
||||
api.broadcastRecord("update", record)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
api.app.OnModelBeforeDelete().Add(func(e *core.ModelEvent) error {
|
||||
if record, ok := e.Model.(*models.Record); ok {
|
||||
api.broadcastRecord("delete", record)
|
||||
if err := api.broadcastRecord("delete", record); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
@@ -250,18 +308,12 @@ func (api *realtimeApi) canAccessRecord(client subscriptions.Client, record *mod
|
||||
}
|
||||
|
||||
// emulate request data
|
||||
requestData := map[string]any{
|
||||
"method": "get",
|
||||
"query": map[string]any{},
|
||||
"data": map[string]any{},
|
||||
"user": nil,
|
||||
}
|
||||
user, _ := client.Get(ContextUserKey).(*models.User)
|
||||
if user != nil {
|
||||
requestData["user"], _ = user.AsMap()
|
||||
requestData := &models.RequestData{
|
||||
Method: "GET",
|
||||
}
|
||||
requestData.AuthRecord, _ = client.Get(ContextAuthRecordKey).(*models.Record)
|
||||
|
||||
resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), record.Collection(), requestData)
|
||||
resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), record.Collection(), requestData, true)
|
||||
expr, err := search.FilterData(*accessRule).BuildExpr(resolver)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -272,7 +324,7 @@ func (api *realtimeApi) canAccessRecord(client subscriptions.Client, record *mod
|
||||
return nil
|
||||
}
|
||||
|
||||
foundRecord, err := api.app.Dao().FindRecordById(record.Collection(), record.Id, ruleFunc)
|
||||
foundRecord, err := api.app.Dao().FindRecordById(record.Collection().Id, record.Id, ruleFunc)
|
||||
if err == nil && foundRecord != nil {
|
||||
return true
|
||||
}
|
||||
@@ -296,24 +348,26 @@ func (api *realtimeApi) broadcastRecord(action string, record *models.Record) er
|
||||
return nil // no subscribers
|
||||
}
|
||||
|
||||
// remove the expand from the broadcasted record because we don't
|
||||
// know if the clients have access to view the expanded records
|
||||
cleanRecord := *record
|
||||
cleanRecord.SetExpand(nil)
|
||||
// create a clean record copy without expand and unknown fields
|
||||
// because we don't know if the clients have permissions to view them
|
||||
cleanRecord := record.CleanCopy()
|
||||
|
||||
subscriptionRuleMap := map[string]*string{
|
||||
(collection.Name + "/" + cleanRecord.Id): collection.ViewRule,
|
||||
(collection.Id + "/" + cleanRecord.Id): collection.ViewRule,
|
||||
collection.Name: collection.ListRule,
|
||||
collection.Id: collection.ListRule,
|
||||
(collection.Name + "/*"): collection.ListRule,
|
||||
(collection.Id + "/*"): collection.ListRule,
|
||||
// @deprecated: the same as the wildcard topic but kept for backward compatibility
|
||||
collection.Name: collection.ListRule,
|
||||
collection.Id: collection.ListRule,
|
||||
}
|
||||
|
||||
data := &recordData{
|
||||
Action: action,
|
||||
Record: &cleanRecord,
|
||||
Record: cleanRecord,
|
||||
}
|
||||
|
||||
serializedData, err := json.Marshal(data)
|
||||
dataBytes, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
if api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
@@ -321,7 +375,11 @@ func (api *realtimeApi) broadcastRecord(action string, record *models.Record) er
|
||||
return err
|
||||
}
|
||||
|
||||
encodedData := string(dataBytes)
|
||||
|
||||
for _, client := range clients {
|
||||
client := client
|
||||
|
||||
for subscription, rule := range subscriptionRuleMap {
|
||||
if !client.HasSubscription(subscription) {
|
||||
continue
|
||||
@@ -333,10 +391,28 @@ func (api *realtimeApi) broadcastRecord(action string, record *models.Record) er
|
||||
|
||||
msg := subscriptions.Message{
|
||||
Name: subscription,
|
||||
Data: string(serializedData),
|
||||
Data: encodedData,
|
||||
}
|
||||
|
||||
client.Channel() <- msg
|
||||
// ignore the auth record email visibility checks for
|
||||
// auth owner, admin or manager
|
||||
if collection.IsAuth() {
|
||||
authId := extractAuthIdFromGetter(client)
|
||||
if authId == data.Record.Id ||
|
||||
api.canAccessRecord(client, data.Record, collection.AuthOptions().ManageRule) {
|
||||
data.Record.IgnoreEmailVisibility(true) // ignore
|
||||
if newData, err := json.Marshal(data); err == nil {
|
||||
msg.Data = string(newData)
|
||||
}
|
||||
data.Record.IgnoreEmailVisibility(false) // restore
|
||||
}
|
||||
}
|
||||
|
||||
routine.FireAndForget(func() {
|
||||
if !client.IsDiscarded() {
|
||||
client.Channel() <- msg
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -348,9 +424,9 @@ type getter interface {
|
||||
}
|
||||
|
||||
func extractAuthIdFromGetter(val getter) string {
|
||||
user, _ := val.Get(ContextUserKey).(*models.User)
|
||||
if user != nil {
|
||||
return user.Id
|
||||
record, _ := val.Get(ContextAuthRecordKey).(*models.Record)
|
||||
if record != nil {
|
||||
return record.Id
|
||||
}
|
||||
|
||||
admin, _ := val.Get(ContextAdminKey).(*models.Admin)
|
||||
|
||||
+92
-41
@@ -1,6 +1,7 @@
|
||||
package apis_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -10,6 +11,7 @@ import (
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
"github.com/pocketbase/pocketbase/tools/hook"
|
||||
"github.com/pocketbase/pocketbase/tools/subscriptions"
|
||||
)
|
||||
|
||||
@@ -25,9 +27,58 @@ func TestRealtimeConnect(t *testing.T) {
|
||||
`data:{"clientId":`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnRealtimeConnectRequest": 1,
|
||||
"OnRealtimeConnectRequest": 1,
|
||||
"OnRealtimeBeforeMessageSend": 1,
|
||||
"OnRealtimeAfterMessageSend": 1,
|
||||
"OnRealtimeDisconnectRequest": 1,
|
||||
},
|
||||
AfterFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
if len(app.SubscriptionsBroker().Clients()) != 0 {
|
||||
t.Errorf("Expected the subscribers to be removed after connection close, found %d", len(app.SubscriptionsBroker().Clients()))
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "PB_CONNECT interrupt",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/realtime",
|
||||
ExpectedStatus: 200,
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnRealtimeConnectRequest": 1,
|
||||
"OnRealtimeBeforeMessageSend": 1,
|
||||
"OnRealtimeDisconnectRequest": 1,
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
app.OnRealtimeBeforeMessageSend().Add(func(e *core.RealtimeMessageEvent) error {
|
||||
if e.Message.Name == "PB_CONNECT" {
|
||||
return errors.New("PB_CONNECT error")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
},
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
if len(app.SubscriptionsBroker().Clients()) != 0 {
|
||||
t.Errorf("Expected the subscribers to be removed after connection close, found %d", len(app.SubscriptionsBroker().Clients()))
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "Skipping/ignoring messages",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/realtime",
|
||||
ExpectedStatus: 200,
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnRealtimeConnectRequest": 1,
|
||||
"OnRealtimeBeforeMessageSend": 1,
|
||||
"OnRealtimeAfterMessageSend": 1,
|
||||
"OnRealtimeDisconnectRequest": 1,
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
app.OnRealtimeBeforeMessageSend().Add(func(e *core.RealtimeMessageEvent) error {
|
||||
return hook.StopPropagation
|
||||
})
|
||||
},
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
if len(app.SubscriptionsBroker().Clients()) != 0 {
|
||||
t.Errorf("Expected the subscribers to be removed after connection close, found %d", len(app.SubscriptionsBroker().Clients()))
|
||||
}
|
||||
@@ -46,7 +97,7 @@ func TestRealtimeSubscribe(t *testing.T) {
|
||||
resetClient := func() {
|
||||
client.Unsubscribe()
|
||||
client.Set(apis.ContextAdminKey, nil)
|
||||
client.Set(apis.ContextUserKey, nil)
|
||||
client.Set(apis.ContextAuthRecordKey, nil)
|
||||
}
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
@@ -68,11 +119,11 @@ func TestRealtimeSubscribe(t *testing.T) {
|
||||
"OnRealtimeBeforeSubscribeRequest": 1,
|
||||
"OnRealtimeAfterSubscribeRequest": 1,
|
||||
},
|
||||
BeforeFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
client.Subscribe("test0")
|
||||
app.SubscriptionsBroker().Register(client)
|
||||
},
|
||||
AfterFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
if len(client.Subscriptions()) != 0 {
|
||||
t.Errorf("Expected no subscriptions, got %v", client.Subscriptions())
|
||||
}
|
||||
@@ -89,11 +140,11 @@ func TestRealtimeSubscribe(t *testing.T) {
|
||||
"OnRealtimeBeforeSubscribeRequest": 1,
|
||||
"OnRealtimeAfterSubscribeRequest": 1,
|
||||
},
|
||||
BeforeFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
client.Subscribe("test0")
|
||||
app.SubscriptionsBroker().Register(client)
|
||||
},
|
||||
AfterFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
expectedSubs := []string{"test1", "test2"}
|
||||
if len(expectedSubs) != len(client.Subscriptions()) {
|
||||
t.Errorf("Expected subscriptions %v, got %v", expectedSubs, client.Subscriptions())
|
||||
@@ -113,17 +164,17 @@ func TestRealtimeSubscribe(t *testing.T) {
|
||||
Url: "/api/realtime",
|
||||
Body: strings.NewReader(`{"clientId":"` + client.Id() + `","subscriptions":["test1", "test2"]}`),
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
ExpectedStatus: 204,
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnRealtimeBeforeSubscribeRequest": 1,
|
||||
"OnRealtimeAfterSubscribeRequest": 1,
|
||||
},
|
||||
BeforeFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
app.SubscriptionsBroker().Register(client)
|
||||
},
|
||||
AfterFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
admin, _ := client.Get(apis.ContextAdminKey).(*models.Admin)
|
||||
if admin == nil {
|
||||
t.Errorf("Expected admin auth model, got nil")
|
||||
@@ -132,25 +183,25 @@ func TestRealtimeSubscribe(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "existing client - authorized user",
|
||||
Name: "existing client - authorized record",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/realtime",
|
||||
Body: strings.NewReader(`{"clientId":"` + client.Id() + `","subscriptions":["test1", "test2"]}`),
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
|
||||
},
|
||||
ExpectedStatus: 204,
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnRealtimeBeforeSubscribeRequest": 1,
|
||||
"OnRealtimeAfterSubscribeRequest": 1,
|
||||
},
|
||||
BeforeFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
app.SubscriptionsBroker().Register(client)
|
||||
},
|
||||
AfterFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
user, _ := client.Get(apis.ContextUserKey).(*models.User)
|
||||
if user == nil {
|
||||
t.Errorf("Expected user auth model, got nil")
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
authRecord, _ := client.Get(apis.ContextAuthRecordKey).(*models.Record)
|
||||
if authRecord == nil {
|
||||
t.Errorf("Expected auth record model, got nil")
|
||||
}
|
||||
resetClient()
|
||||
},
|
||||
@@ -161,21 +212,21 @@ func TestRealtimeSubscribe(t *testing.T) {
|
||||
Url: "/api/realtime",
|
||||
Body: strings.NewReader(`{"clientId":"` + client.Id() + `","subscriptions":["test1", "test2"]}`),
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
|
||||
},
|
||||
ExpectedStatus: 403,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
BeforeFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
initialAuth := &models.User{}
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
initialAuth := &models.Record{}
|
||||
initialAuth.RefreshId()
|
||||
client.Set(apis.ContextUserKey, initialAuth)
|
||||
client.Set(apis.ContextAuthRecordKey, initialAuth)
|
||||
|
||||
app.SubscriptionsBroker().Register(client)
|
||||
},
|
||||
AfterFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
user, _ := client.Get(apis.ContextUserKey).(*models.User)
|
||||
if user == nil {
|
||||
t.Errorf("Expected user auth model, got nil")
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
authRecord, _ := client.Get(apis.ContextAuthRecordKey).(*models.Record)
|
||||
if authRecord == nil {
|
||||
t.Errorf("Expected auth record model, got nil")
|
||||
}
|
||||
resetClient()
|
||||
},
|
||||
@@ -187,55 +238,55 @@ func TestRealtimeSubscribe(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRealtimeUserDeleteEvent(t *testing.T) {
|
||||
func TestRealtimeAuthRecordDeleteEvent(t *testing.T) {
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
|
||||
apis.InitApi(testApp)
|
||||
|
||||
user, err := testApp.Dao().FindUserByEmail("test@example.com")
|
||||
authRecord, err := testApp.Dao().FindFirstRecordByData("users", "email", "test@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
client := subscriptions.NewDefaultClient()
|
||||
client.Set(apis.ContextUserKey, user)
|
||||
client.Set(apis.ContextAuthRecordKey, authRecord)
|
||||
testApp.SubscriptionsBroker().Register(client)
|
||||
|
||||
testApp.OnModelAfterDelete().Trigger(&core.ModelEvent{Dao: testApp.Dao(), Model: user})
|
||||
testApp.OnModelAfterDelete().Trigger(&core.ModelEvent{Dao: testApp.Dao(), Model: authRecord})
|
||||
|
||||
if len(testApp.SubscriptionsBroker().Clients()) != 0 {
|
||||
t.Fatalf("Expected no subscription clients, found %d", len(testApp.SubscriptionsBroker().Clients()))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRealtimeUserUpdateEvent(t *testing.T) {
|
||||
func TestRealtimeAuthRecordUpdateEvent(t *testing.T) {
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
|
||||
apis.InitApi(testApp)
|
||||
|
||||
user1, err := testApp.Dao().FindUserByEmail("test@example.com")
|
||||
authRecord1, err := testApp.Dao().FindFirstRecordByData("users", "email", "test@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
client := subscriptions.NewDefaultClient()
|
||||
client.Set(apis.ContextUserKey, user1)
|
||||
client.Set(apis.ContextAuthRecordKey, authRecord1)
|
||||
testApp.SubscriptionsBroker().Register(client)
|
||||
|
||||
// refetch the user and change its email
|
||||
user2, err := testApp.Dao().FindUserByEmail("test@example.com")
|
||||
// refetch the authRecord and change its email
|
||||
authRecord2, err := testApp.Dao().FindFirstRecordByData("users", "email", "test@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
user2.Email = "new@example.com"
|
||||
authRecord2.SetEmail("new@example.com")
|
||||
|
||||
testApp.OnModelAfterUpdate().Trigger(&core.ModelEvent{Dao: testApp.Dao(), Model: user2})
|
||||
testApp.OnModelAfterUpdate().Trigger(&core.ModelEvent{Dao: testApp.Dao(), Model: authRecord2})
|
||||
|
||||
clientUser, _ := client.Get(apis.ContextUserKey).(*models.User)
|
||||
if clientUser.Email != user2.Email {
|
||||
t.Fatalf("Expected user with email %q, got %q", user2.Email, clientUser.Email)
|
||||
clientAuthRecord, _ := client.Get(apis.ContextAuthRecordKey).(*models.Record)
|
||||
if clientAuthRecord.Email() != authRecord2.Email() {
|
||||
t.Fatalf("Expected authRecord with email %q, got %q", authRecord2.Email(), clientAuthRecord.Email())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -276,7 +327,7 @@ func TestRealtimeAdminUpdateEvent(t *testing.T) {
|
||||
client.Set(apis.ContextAdminKey, admin1)
|
||||
testApp.SubscriptionsBroker().Register(client)
|
||||
|
||||
// refetch the user and change its email
|
||||
// refetch the authRecord and change its email
|
||||
admin2, err := testApp.Dao().FindAdminByEmail("test@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -287,6 +338,6 @@ func TestRealtimeAdminUpdateEvent(t *testing.T) {
|
||||
|
||||
clientAdmin, _ := client.Get(apis.ContextAdminKey).(*models.Admin)
|
||||
if clientAdmin.Email != admin2.Email {
|
||||
t.Fatalf("Expected user with email %q, got %q", admin2.Email, clientAdmin.Email)
|
||||
t.Fatalf("Expected authRecord with email %q, got %q", admin2.Email, clientAdmin.Email)
|
||||
}
|
||||
}
|
||||
|
||||
-454
@@ -1,454 +0,0 @@
|
||||
package apis
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
"github.com/pocketbase/pocketbase/forms"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/resolvers"
|
||||
"github.com/pocketbase/pocketbase/tools/rest"
|
||||
"github.com/pocketbase/pocketbase/tools/search"
|
||||
)
|
||||
|
||||
const expandQueryParam = "expand"
|
||||
|
||||
// BindRecordApi registers the record api endpoints and the corresponding handlers.
|
||||
func BindRecordApi(app core.App, rg *echo.Group) {
|
||||
api := recordApi{app: app}
|
||||
|
||||
subGroup := rg.Group(
|
||||
"/collections/:collection/records",
|
||||
ActivityLogger(app),
|
||||
LoadCollectionContext(app),
|
||||
)
|
||||
|
||||
subGroup.GET("", api.list)
|
||||
subGroup.POST("", api.create)
|
||||
subGroup.GET("/:id", api.view)
|
||||
subGroup.PATCH("/:id", api.update)
|
||||
subGroup.DELETE("/:id", api.delete)
|
||||
}
|
||||
|
||||
type recordApi struct {
|
||||
app core.App
|
||||
}
|
||||
|
||||
func (api *recordApi) list(c echo.Context) error {
|
||||
collection, _ := c.Get(ContextCollectionKey).(*models.Collection)
|
||||
if collection == nil {
|
||||
return rest.NewNotFoundError("", "Missing collection context.")
|
||||
}
|
||||
|
||||
admin, _ := c.Get(ContextAdminKey).(*models.Admin)
|
||||
if admin == nil && collection.ListRule == nil {
|
||||
// only admins can access if the rule is nil
|
||||
return rest.NewForbiddenError("Only admins can perform this action.", nil)
|
||||
}
|
||||
|
||||
// forbid users and guests to query special filter/sort fields
|
||||
if err := api.checkForForbiddenQueryFields(c); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
requestData := api.exportRequestData(c)
|
||||
|
||||
fieldsResolver := resolvers.NewRecordFieldResolver(api.app.Dao(), collection, requestData)
|
||||
|
||||
searchProvider := search.NewProvider(fieldsResolver).
|
||||
Query(api.app.Dao().RecordQuery(collection)).
|
||||
CountColumn(fmt.Sprintf("%s.id", api.app.Dao().DB().QuoteSimpleColumnName(collection.Name)))
|
||||
|
||||
if admin == nil && collection.ListRule != nil {
|
||||
searchProvider.AddFilter(search.FilterData(*collection.ListRule))
|
||||
}
|
||||
|
||||
var rawRecords = []dbx.NullStringMap{}
|
||||
result, err := searchProvider.ParseAndExec(c.QueryString(), &rawRecords)
|
||||
if err != nil {
|
||||
return rest.NewBadRequestError("Invalid filter parameters.", err)
|
||||
}
|
||||
|
||||
records := models.NewRecordsFromNullStringMaps(collection, rawRecords)
|
||||
|
||||
// expand records relations
|
||||
expands := strings.Split(c.QueryParam(expandQueryParam), ",")
|
||||
if len(expands) > 0 {
|
||||
failed := api.app.Dao().ExpandRecords(
|
||||
records,
|
||||
expands,
|
||||
api.expandFunc(c, requestData),
|
||||
)
|
||||
if len(failed) > 0 && api.app.IsDebug() {
|
||||
log.Println("Failed to expand relations: ", failed)
|
||||
}
|
||||
}
|
||||
|
||||
result.Items = records
|
||||
|
||||
event := &core.RecordsListEvent{
|
||||
HttpContext: c,
|
||||
Collection: collection,
|
||||
Records: records,
|
||||
Result: result,
|
||||
}
|
||||
|
||||
return api.app.OnRecordsListRequest().Trigger(event, func(e *core.RecordsListEvent) error {
|
||||
return e.HttpContext.JSON(http.StatusOK, e.Result)
|
||||
})
|
||||
}
|
||||
|
||||
func (api *recordApi) view(c echo.Context) error {
|
||||
collection, _ := c.Get(ContextCollectionKey).(*models.Collection)
|
||||
if collection == nil {
|
||||
return rest.NewNotFoundError("", "Missing collection context.")
|
||||
}
|
||||
|
||||
admin, _ := c.Get(ContextAdminKey).(*models.Admin)
|
||||
if admin == nil && collection.ViewRule == nil {
|
||||
// only admins can access if the rule is nil
|
||||
return rest.NewForbiddenError("Only admins can perform this action.", nil)
|
||||
}
|
||||
|
||||
recordId := c.PathParam("id")
|
||||
if recordId == "" {
|
||||
return rest.NewNotFoundError("", nil)
|
||||
}
|
||||
|
||||
requestData := api.exportRequestData(c)
|
||||
|
||||
ruleFunc := func(q *dbx.SelectQuery) error {
|
||||
if admin == nil && collection.ViewRule != nil && *collection.ViewRule != "" {
|
||||
resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), collection, requestData)
|
||||
expr, err := search.FilterData(*collection.ViewRule).BuildExpr(resolver)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resolver.UpdateQuery(q)
|
||||
q.AndWhere(expr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
record, fetchErr := api.app.Dao().FindRecordById(collection, recordId, ruleFunc)
|
||||
if fetchErr != nil || record == nil {
|
||||
return rest.NewNotFoundError("", fetchErr)
|
||||
}
|
||||
|
||||
// expand record relations
|
||||
failed := api.app.Dao().ExpandRecord(
|
||||
record,
|
||||
strings.Split(c.QueryParam(expandQueryParam), ","),
|
||||
api.expandFunc(c, requestData),
|
||||
)
|
||||
if len(failed) > 0 && api.app.IsDebug() {
|
||||
log.Println("Failed to expand relations: ", failed)
|
||||
}
|
||||
|
||||
event := &core.RecordViewEvent{
|
||||
HttpContext: c,
|
||||
Record: record,
|
||||
}
|
||||
|
||||
return api.app.OnRecordViewRequest().Trigger(event, func(e *core.RecordViewEvent) error {
|
||||
return e.HttpContext.JSON(http.StatusOK, e.Record)
|
||||
})
|
||||
}
|
||||
|
||||
func (api *recordApi) create(c echo.Context) error {
|
||||
collection, _ := c.Get(ContextCollectionKey).(*models.Collection)
|
||||
if collection == nil {
|
||||
return rest.NewNotFoundError("", "Missing collection context.")
|
||||
}
|
||||
|
||||
admin, _ := c.Get(ContextAdminKey).(*models.Admin)
|
||||
if admin == nil && collection.CreateRule == nil {
|
||||
// only admins can access if the rule is nil
|
||||
return rest.NewForbiddenError("Only admins can perform this action.", nil)
|
||||
}
|
||||
|
||||
requestData := api.exportRequestData(c)
|
||||
|
||||
// temporary save the record and check it against the create rule
|
||||
if admin == nil && collection.CreateRule != nil && *collection.CreateRule != "" {
|
||||
ruleFunc := func(q *dbx.SelectQuery) error {
|
||||
resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), collection, requestData)
|
||||
expr, err := search.FilterData(*collection.CreateRule).BuildExpr(resolver)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resolver.UpdateQuery(q)
|
||||
q.AndWhere(expr)
|
||||
return nil
|
||||
}
|
||||
|
||||
testRecord := models.NewRecord(collection)
|
||||
testForm := forms.NewRecordUpsert(api.app, testRecord)
|
||||
if err := testForm.LoadData(c.Request()); err != nil {
|
||||
return rest.NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
|
||||
}
|
||||
|
||||
testErr := testForm.DrySubmit(func(txDao *daos.Dao) error {
|
||||
_, fetchErr := txDao.FindRecordById(collection, testRecord.Id, ruleFunc)
|
||||
return fetchErr
|
||||
})
|
||||
if testErr != nil {
|
||||
return rest.NewBadRequestError("Failed to create record.", testErr)
|
||||
}
|
||||
}
|
||||
|
||||
record := models.NewRecord(collection)
|
||||
form := forms.NewRecordUpsert(api.app, record)
|
||||
|
||||
// load request
|
||||
if err := form.LoadData(c.Request()); err != nil {
|
||||
return rest.NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
|
||||
}
|
||||
|
||||
event := &core.RecordCreateEvent{
|
||||
HttpContext: c,
|
||||
Record: record,
|
||||
}
|
||||
|
||||
// create the record
|
||||
submitErr := form.Submit(func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
|
||||
return func() error {
|
||||
return api.app.OnRecordBeforeCreateRequest().Trigger(event, func(e *core.RecordCreateEvent) error {
|
||||
if err := next(); err != nil {
|
||||
return rest.NewBadRequestError("Failed to create record.", err)
|
||||
}
|
||||
|
||||
// expand record relations
|
||||
failed := api.app.Dao().ExpandRecord(
|
||||
e.Record,
|
||||
strings.Split(e.HttpContext.QueryParam(expandQueryParam), ","),
|
||||
api.expandFunc(e.HttpContext, requestData),
|
||||
)
|
||||
if len(failed) > 0 && api.app.IsDebug() {
|
||||
log.Println("Failed to expand relations: ", failed)
|
||||
}
|
||||
|
||||
return e.HttpContext.JSON(http.StatusOK, e.Record)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if submitErr == nil {
|
||||
api.app.OnRecordAfterCreateRequest().Trigger(event)
|
||||
}
|
||||
|
||||
return submitErr
|
||||
}
|
||||
|
||||
func (api *recordApi) update(c echo.Context) error {
|
||||
collection, _ := c.Get(ContextCollectionKey).(*models.Collection)
|
||||
if collection == nil {
|
||||
return rest.NewNotFoundError("", "Missing collection context.")
|
||||
}
|
||||
|
||||
admin, _ := c.Get(ContextAdminKey).(*models.Admin)
|
||||
if admin == nil && collection.UpdateRule == nil {
|
||||
// only admins can access if the rule is nil
|
||||
return rest.NewForbiddenError("Only admins can perform this action.", nil)
|
||||
}
|
||||
|
||||
recordId := c.PathParam("id")
|
||||
if recordId == "" {
|
||||
return rest.NewNotFoundError("", nil)
|
||||
}
|
||||
|
||||
requestData := api.exportRequestData(c)
|
||||
|
||||
ruleFunc := func(q *dbx.SelectQuery) error {
|
||||
if admin == nil && collection.UpdateRule != nil && *collection.UpdateRule != "" {
|
||||
resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), collection, requestData)
|
||||
expr, err := search.FilterData(*collection.UpdateRule).BuildExpr(resolver)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resolver.UpdateQuery(q)
|
||||
q.AndWhere(expr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// fetch record
|
||||
record, fetchErr := api.app.Dao().FindRecordById(collection, recordId, ruleFunc)
|
||||
if fetchErr != nil || record == nil {
|
||||
return rest.NewNotFoundError("", fetchErr)
|
||||
}
|
||||
|
||||
form := forms.NewRecordUpsert(api.app, record)
|
||||
|
||||
// load request
|
||||
if err := form.LoadData(c.Request()); err != nil {
|
||||
return rest.NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
|
||||
}
|
||||
|
||||
event := &core.RecordUpdateEvent{
|
||||
HttpContext: c,
|
||||
Record: record,
|
||||
}
|
||||
|
||||
// update the record
|
||||
submitErr := form.Submit(func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
|
||||
return func() error {
|
||||
return api.app.OnRecordBeforeUpdateRequest().Trigger(event, func(e *core.RecordUpdateEvent) error {
|
||||
if err := next(); err != nil {
|
||||
return rest.NewBadRequestError("Failed to update record.", err)
|
||||
}
|
||||
|
||||
// expand record relations
|
||||
failed := api.app.Dao().ExpandRecord(
|
||||
e.Record,
|
||||
strings.Split(e.HttpContext.QueryParam(expandQueryParam), ","),
|
||||
api.expandFunc(e.HttpContext, requestData),
|
||||
)
|
||||
if len(failed) > 0 && api.app.IsDebug() {
|
||||
log.Println("Failed to expand relations: ", failed)
|
||||
}
|
||||
|
||||
return e.HttpContext.JSON(http.StatusOK, e.Record)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if submitErr == nil {
|
||||
api.app.OnRecordAfterUpdateRequest().Trigger(event)
|
||||
}
|
||||
|
||||
return submitErr
|
||||
}
|
||||
|
||||
func (api *recordApi) delete(c echo.Context) error {
|
||||
collection, _ := c.Get(ContextCollectionKey).(*models.Collection)
|
||||
if collection == nil {
|
||||
return rest.NewNotFoundError("", "Missing collection context.")
|
||||
}
|
||||
|
||||
admin, _ := c.Get(ContextAdminKey).(*models.Admin)
|
||||
if admin == nil && collection.DeleteRule == nil {
|
||||
// only admins can access if the rule is nil
|
||||
return rest.NewForbiddenError("Only admins can perform this action.", nil)
|
||||
}
|
||||
|
||||
recordId := c.PathParam("id")
|
||||
if recordId == "" {
|
||||
return rest.NewNotFoundError("", nil)
|
||||
}
|
||||
|
||||
requestData := api.exportRequestData(c)
|
||||
|
||||
ruleFunc := func(q *dbx.SelectQuery) error {
|
||||
if admin == nil && collection.DeleteRule != nil && *collection.DeleteRule != "" {
|
||||
resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), collection, requestData)
|
||||
expr, err := search.FilterData(*collection.DeleteRule).BuildExpr(resolver)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resolver.UpdateQuery(q)
|
||||
q.AndWhere(expr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
record, fetchErr := api.app.Dao().FindRecordById(collection, recordId, ruleFunc)
|
||||
if fetchErr != nil || record == nil {
|
||||
return rest.NewNotFoundError("", fetchErr)
|
||||
}
|
||||
|
||||
event := &core.RecordDeleteEvent{
|
||||
HttpContext: c,
|
||||
Record: record,
|
||||
}
|
||||
|
||||
handlerErr := api.app.OnRecordBeforeDeleteRequest().Trigger(event, func(e *core.RecordDeleteEvent) error {
|
||||
// delete the record
|
||||
if err := api.app.Dao().DeleteRecord(e.Record); err != nil {
|
||||
return rest.NewBadRequestError("Failed to delete record. Make sure that the record is not part of a required relation reference.", err)
|
||||
}
|
||||
|
||||
return e.HttpContext.NoContent(http.StatusNoContent)
|
||||
})
|
||||
|
||||
if handlerErr == nil {
|
||||
api.app.OnRecordAfterDeleteRequest().Trigger(event)
|
||||
}
|
||||
|
||||
return handlerErr
|
||||
}
|
||||
|
||||
func (api *recordApi) exportRequestData(c echo.Context) map[string]any {
|
||||
result := map[string]any{}
|
||||
queryParams := map[string]any{}
|
||||
bodyData := map[string]any{}
|
||||
method := c.Request().Method
|
||||
|
||||
echo.BindQueryParams(c, &queryParams)
|
||||
|
||||
rest.BindBody(c, &bodyData)
|
||||
|
||||
result["method"] = method
|
||||
result["query"] = queryParams
|
||||
result["data"] = bodyData
|
||||
result["user"] = nil
|
||||
|
||||
loggedUser, _ := c.Get(ContextUserKey).(*models.User)
|
||||
if loggedUser != nil {
|
||||
result["user"], _ = loggedUser.AsMap()
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func (api *recordApi) checkForForbiddenQueryFields(c echo.Context) error {
|
||||
admin, _ := c.Get(ContextAdminKey).(*models.Admin)
|
||||
if admin != nil {
|
||||
return nil // admins are allowed to query everything
|
||||
}
|
||||
|
||||
decodedQuery := c.QueryParam(search.FilterQueryParam) + c.QueryParam(search.SortQueryParam)
|
||||
forbiddenFields := []string{"@collection.", "@request."}
|
||||
|
||||
for _, field := range forbiddenFields {
|
||||
if strings.Contains(decodedQuery, field) {
|
||||
return rest.NewForbiddenError("Only admins can filter by @collection and @request query params", nil)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (api *recordApi) expandFunc(c echo.Context, requestData map[string]any) daos.ExpandFetchFunc {
|
||||
admin, _ := c.Get(ContextAdminKey).(*models.Admin)
|
||||
|
||||
return func(relCollection *models.Collection, relIds []string) ([]*models.Record, error) {
|
||||
return api.app.Dao().FindRecordsByIds(relCollection, relIds, func(q *dbx.SelectQuery) error {
|
||||
if admin != nil {
|
||||
return nil // admin can access everything
|
||||
}
|
||||
|
||||
if relCollection.ViewRule == nil {
|
||||
return fmt.Errorf("Only admins can view collection %q records", relCollection.Name)
|
||||
}
|
||||
|
||||
if *relCollection.ViewRule != "" {
|
||||
resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), relCollection, requestData)
|
||||
expr, err := search.FilterData(*(relCollection.ViewRule)).BuildExpr(resolver)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resolver.UpdateQuery(q)
|
||||
q.AndWhere(expr)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,617 @@
|
||||
package apis
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
"github.com/pocketbase/pocketbase/forms"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/resolvers"
|
||||
"github.com/pocketbase/pocketbase/tools/auth"
|
||||
"github.com/pocketbase/pocketbase/tools/routine"
|
||||
"github.com/pocketbase/pocketbase/tools/search"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
// bindRecordAuthApi registers the auth record api endpoints and
|
||||
// the corresponding handlers.
|
||||
func bindRecordAuthApi(app core.App, rg *echo.Group) {
|
||||
api := recordAuthApi{app: app}
|
||||
|
||||
subGroup := rg.Group(
|
||||
"/collections/:collection",
|
||||
ActivityLogger(app),
|
||||
LoadCollectionContext(app, models.CollectionTypeAuth),
|
||||
)
|
||||
|
||||
subGroup.GET("/auth-methods", api.authMethods)
|
||||
subGroup.POST("/auth-refresh", api.authRefresh, RequireSameContextRecordAuth())
|
||||
subGroup.POST("/auth-with-oauth2", api.authWithOAuth2)
|
||||
subGroup.POST("/auth-with-password", api.authWithPassword)
|
||||
subGroup.POST("/request-password-reset", api.requestPasswordReset)
|
||||
subGroup.POST("/confirm-password-reset", api.confirmPasswordReset)
|
||||
subGroup.POST("/request-verification", api.requestVerification)
|
||||
subGroup.POST("/confirm-verification", api.confirmVerification)
|
||||
subGroup.POST("/request-email-change", api.requestEmailChange, RequireSameContextRecordAuth())
|
||||
subGroup.POST("/confirm-email-change", api.confirmEmailChange)
|
||||
subGroup.GET("/records/:id/external-auths", api.listExternalAuths, RequireAdminOrOwnerAuth("id"))
|
||||
subGroup.DELETE("/records/:id/external-auths/:provider", api.unlinkExternalAuth, RequireAdminOrOwnerAuth("id"))
|
||||
}
|
||||
|
||||
type recordAuthApi struct {
|
||||
app core.App
|
||||
}
|
||||
|
||||
func (api *recordAuthApi) authRefresh(c echo.Context) error {
|
||||
record, _ := c.Get(ContextAuthRecordKey).(*models.Record)
|
||||
if record == nil {
|
||||
return NewNotFoundError("Missing auth record context.", nil)
|
||||
}
|
||||
|
||||
event := new(core.RecordAuthRefreshEvent)
|
||||
event.HttpContext = c
|
||||
event.Collection = record.Collection()
|
||||
event.Record = record
|
||||
|
||||
handlerErr := api.app.OnRecordBeforeAuthRefreshRequest().Trigger(event, func(e *core.RecordAuthRefreshEvent) error {
|
||||
return RecordAuthResponse(api.app, e.HttpContext, e.Record, nil)
|
||||
})
|
||||
|
||||
if handlerErr == nil {
|
||||
if err := api.app.OnRecordAfterAuthRefreshRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return handlerErr
|
||||
}
|
||||
|
||||
type providerInfo struct {
|
||||
Name string `json:"name"`
|
||||
State string `json:"state"`
|
||||
CodeVerifier string `json:"codeVerifier"`
|
||||
CodeChallenge string `json:"codeChallenge"`
|
||||
CodeChallengeMethod string `json:"codeChallengeMethod"`
|
||||
AuthUrl string `json:"authUrl"`
|
||||
}
|
||||
|
||||
func (api *recordAuthApi) authMethods(c echo.Context) error {
|
||||
collection, _ := c.Get(ContextCollectionKey).(*models.Collection)
|
||||
if collection == nil {
|
||||
return NewNotFoundError("Missing collection context.", nil)
|
||||
}
|
||||
|
||||
authOptions := collection.AuthOptions()
|
||||
|
||||
result := struct {
|
||||
UsernamePassword bool `json:"usernamePassword"`
|
||||
EmailPassword bool `json:"emailPassword"`
|
||||
AuthProviders []providerInfo `json:"authProviders"`
|
||||
}{
|
||||
UsernamePassword: authOptions.AllowUsernameAuth,
|
||||
EmailPassword: authOptions.AllowEmailAuth,
|
||||
AuthProviders: []providerInfo{},
|
||||
}
|
||||
|
||||
if !authOptions.AllowOAuth2Auth {
|
||||
return c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
nameConfigMap := api.app.Settings().NamedAuthProviderConfigs()
|
||||
for name, config := range nameConfigMap {
|
||||
if !config.Enabled {
|
||||
continue
|
||||
}
|
||||
|
||||
provider, err := auth.NewProviderByName(name)
|
||||
if err != nil {
|
||||
if api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
continue // skip provider
|
||||
}
|
||||
|
||||
if err := config.SetupProvider(provider); err != nil {
|
||||
if api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
continue // skip provider
|
||||
}
|
||||
|
||||
state := security.RandomString(30)
|
||||
codeVerifier := security.RandomString(43)
|
||||
codeChallenge := security.S256Challenge(codeVerifier)
|
||||
codeChallengeMethod := "S256"
|
||||
result.AuthProviders = append(result.AuthProviders, providerInfo{
|
||||
Name: name,
|
||||
State: state,
|
||||
CodeVerifier: codeVerifier,
|
||||
CodeChallenge: codeChallenge,
|
||||
CodeChallengeMethod: codeChallengeMethod,
|
||||
AuthUrl: provider.BuildAuthUrl(
|
||||
state,
|
||||
oauth2.SetAuthURLParam("code_challenge", codeChallenge),
|
||||
oauth2.SetAuthURLParam("code_challenge_method", codeChallengeMethod),
|
||||
) + "&redirect_uri=", // empty redirect_uri so that users can append their url
|
||||
})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
func (api *recordAuthApi) authWithOAuth2(c echo.Context) error {
|
||||
collection, _ := c.Get(ContextCollectionKey).(*models.Collection)
|
||||
if collection == nil {
|
||||
return NewNotFoundError("Missing collection context.", nil)
|
||||
}
|
||||
|
||||
if !collection.AuthOptions().AllowOAuth2Auth {
|
||||
return NewBadRequestError("The collection is not configured to allow OAuth2 authentication.", nil)
|
||||
}
|
||||
|
||||
var fallbackAuthRecord *models.Record
|
||||
|
||||
loggedAuthRecord, _ := c.Get(ContextAuthRecordKey).(*models.Record)
|
||||
if loggedAuthRecord != nil && loggedAuthRecord.Collection().Id == collection.Id {
|
||||
fallbackAuthRecord = loggedAuthRecord
|
||||
}
|
||||
|
||||
form := forms.NewRecordOAuth2Login(api.app, collection, fallbackAuthRecord)
|
||||
if readErr := c.Bind(form); readErr != nil {
|
||||
return NewBadRequestError("An error occurred while loading the submitted data.", readErr)
|
||||
}
|
||||
|
||||
form.SetBeforeNewRecordCreateFunc(func(createForm *forms.RecordUpsert, authRecord *models.Record, authUser *auth.AuthUser) error {
|
||||
return createForm.DrySubmit(func(txDao *daos.Dao) error {
|
||||
requestData := RequestData(c)
|
||||
requestData.Data = form.CreateData
|
||||
|
||||
createRuleFunc := func(q *dbx.SelectQuery) error {
|
||||
admin, _ := c.Get(ContextAdminKey).(*models.Admin)
|
||||
if admin != nil {
|
||||
return nil // either admin or the rule is empty
|
||||
}
|
||||
|
||||
if collection.CreateRule == nil {
|
||||
return errors.New("Only admins can create new accounts with OAuth2")
|
||||
}
|
||||
|
||||
if *collection.CreateRule != "" {
|
||||
resolver := resolvers.NewRecordFieldResolver(txDao, collection, requestData, true)
|
||||
expr, err := search.FilterData(*collection.CreateRule).BuildExpr(resolver)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resolver.UpdateQuery(q)
|
||||
q.AndWhere(expr)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
if _, err := txDao.FindRecordById(collection.Id, createForm.Id, createRuleFunc); err != nil {
|
||||
return fmt.Errorf("Failed create rule constraint: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
})
|
||||
|
||||
event := new(core.RecordAuthWithOAuth2Event)
|
||||
event.HttpContext = c
|
||||
event.Collection = collection
|
||||
|
||||
_, _, submitErr := form.Submit(func(next forms.InterceptorNextFunc[*forms.RecordOAuth2LoginData]) forms.InterceptorNextFunc[*forms.RecordOAuth2LoginData] {
|
||||
return func(data *forms.RecordOAuth2LoginData) error {
|
||||
event.Record = data.Record
|
||||
event.OAuth2User = data.OAuth2User
|
||||
|
||||
return api.app.OnRecordBeforeAuthWithOAuth2Request().Trigger(event, func(e *core.RecordAuthWithOAuth2Event) error {
|
||||
data.Record = e.Record
|
||||
data.OAuth2User = e.OAuth2User
|
||||
|
||||
if err := next(data); err != nil {
|
||||
return NewBadRequestError("Failed to authenticate.", err)
|
||||
}
|
||||
|
||||
e.Record = data.Record
|
||||
e.OAuth2User = data.OAuth2User
|
||||
|
||||
return RecordAuthResponse(api.app, e.HttpContext, e.Record, e.OAuth2User)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if submitErr == nil {
|
||||
if err := api.app.OnRecordAfterAuthWithOAuth2Request().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return submitErr
|
||||
}
|
||||
|
||||
func (api *recordAuthApi) authWithPassword(c echo.Context) error {
|
||||
collection, _ := c.Get(ContextCollectionKey).(*models.Collection)
|
||||
if collection == nil {
|
||||
return NewNotFoundError("Missing collection context.", nil)
|
||||
}
|
||||
|
||||
form := forms.NewRecordPasswordLogin(api.app, collection)
|
||||
if readErr := c.Bind(form); readErr != nil {
|
||||
return NewBadRequestError("An error occurred while loading the submitted data.", readErr)
|
||||
}
|
||||
|
||||
event := new(core.RecordAuthWithPasswordEvent)
|
||||
event.HttpContext = c
|
||||
event.Collection = collection
|
||||
event.Password = form.Password
|
||||
event.Identity = form.Identity
|
||||
|
||||
_, submitErr := form.Submit(func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
|
||||
return func(record *models.Record) error {
|
||||
event.Record = record
|
||||
|
||||
return api.app.OnRecordBeforeAuthWithPasswordRequest().Trigger(event, func(e *core.RecordAuthWithPasswordEvent) error {
|
||||
if err := next(e.Record); err != nil {
|
||||
return NewBadRequestError("Failed to authenticate.", err)
|
||||
}
|
||||
|
||||
return RecordAuthResponse(api.app, e.HttpContext, e.Record, nil)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if submitErr == nil {
|
||||
if err := api.app.OnRecordAfterAuthWithPasswordRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return submitErr
|
||||
}
|
||||
|
||||
func (api *recordAuthApi) requestPasswordReset(c echo.Context) error {
|
||||
collection, _ := c.Get(ContextCollectionKey).(*models.Collection)
|
||||
if collection == nil {
|
||||
return NewNotFoundError("Missing collection context.", nil)
|
||||
}
|
||||
|
||||
authOptions := collection.AuthOptions()
|
||||
if !authOptions.AllowUsernameAuth && !authOptions.AllowEmailAuth {
|
||||
return NewBadRequestError("The collection is not configured to allow password authentication.", nil)
|
||||
}
|
||||
|
||||
form := forms.NewRecordPasswordResetRequest(api.app, collection)
|
||||
if err := c.Bind(form); err != nil {
|
||||
return NewBadRequestError("An error occurred while loading the submitted data.", err)
|
||||
}
|
||||
|
||||
if err := form.Validate(); err != nil {
|
||||
return NewBadRequestError("An error occurred while validating the form.", err)
|
||||
}
|
||||
|
||||
event := new(core.RecordRequestPasswordResetEvent)
|
||||
event.HttpContext = c
|
||||
event.Collection = collection
|
||||
|
||||
submitErr := form.Submit(func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
|
||||
return func(record *models.Record) error {
|
||||
event.Record = record
|
||||
|
||||
return api.app.OnRecordBeforeRequestPasswordResetRequest().Trigger(event, func(e *core.RecordRequestPasswordResetEvent) error {
|
||||
// run in background because we don't need to show the result to the client
|
||||
routine.FireAndForget(func() {
|
||||
if err := next(e.Record); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
})
|
||||
|
||||
return e.HttpContext.NoContent(http.StatusNoContent)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if submitErr == nil {
|
||||
if err := api.app.OnRecordAfterRequestPasswordResetRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
} else if api.app.IsDebug() {
|
||||
log.Println(submitErr)
|
||||
}
|
||||
|
||||
// don't return the response error to prevent emails enumeration
|
||||
if !c.Response().Committed {
|
||||
c.NoContent(http.StatusNoContent)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (api *recordAuthApi) confirmPasswordReset(c echo.Context) error {
|
||||
collection, _ := c.Get(ContextCollectionKey).(*models.Collection)
|
||||
if collection == nil {
|
||||
return NewNotFoundError("Missing collection context.", nil)
|
||||
}
|
||||
|
||||
form := forms.NewRecordPasswordResetConfirm(api.app, collection)
|
||||
if readErr := c.Bind(form); readErr != nil {
|
||||
return NewBadRequestError("An error occurred while loading the submitted data.", readErr)
|
||||
}
|
||||
|
||||
event := new(core.RecordConfirmPasswordResetEvent)
|
||||
event.HttpContext = c
|
||||
event.Collection = collection
|
||||
|
||||
_, submitErr := form.Submit(func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
|
||||
return func(record *models.Record) error {
|
||||
event.Record = record
|
||||
|
||||
return api.app.OnRecordBeforeConfirmPasswordResetRequest().Trigger(event, func(e *core.RecordConfirmPasswordResetEvent) error {
|
||||
if err := next(e.Record); err != nil {
|
||||
return NewBadRequestError("Failed to set new password.", err)
|
||||
}
|
||||
|
||||
return e.HttpContext.NoContent(http.StatusNoContent)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if submitErr == nil {
|
||||
if err := api.app.OnRecordAfterConfirmPasswordResetRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return submitErr
|
||||
}
|
||||
|
||||
func (api *recordAuthApi) requestVerification(c echo.Context) error {
|
||||
collection, _ := c.Get(ContextCollectionKey).(*models.Collection)
|
||||
if collection == nil {
|
||||
return NewNotFoundError("Missing collection context.", nil)
|
||||
}
|
||||
|
||||
form := forms.NewRecordVerificationRequest(api.app, collection)
|
||||
if err := c.Bind(form); err != nil {
|
||||
return NewBadRequestError("An error occurred while loading the submitted data.", err)
|
||||
}
|
||||
|
||||
if err := form.Validate(); err != nil {
|
||||
return NewBadRequestError("An error occurred while validating the form.", err)
|
||||
}
|
||||
|
||||
event := new(core.RecordRequestVerificationEvent)
|
||||
event.HttpContext = c
|
||||
event.Collection = collection
|
||||
|
||||
submitErr := form.Submit(func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
|
||||
return func(record *models.Record) error {
|
||||
event.Record = record
|
||||
|
||||
return api.app.OnRecordBeforeRequestVerificationRequest().Trigger(event, func(e *core.RecordRequestVerificationEvent) error {
|
||||
// run in background because we don't need to show the result to the client
|
||||
routine.FireAndForget(func() {
|
||||
if err := next(e.Record); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
})
|
||||
|
||||
return e.HttpContext.NoContent(http.StatusNoContent)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if submitErr == nil {
|
||||
if err := api.app.OnRecordAfterRequestVerificationRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
} else if api.app.IsDebug() {
|
||||
log.Println(submitErr)
|
||||
}
|
||||
|
||||
// don't return the response error to prevent emails enumeration
|
||||
if !c.Response().Committed {
|
||||
c.NoContent(http.StatusNoContent)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (api *recordAuthApi) confirmVerification(c echo.Context) error {
|
||||
collection, _ := c.Get(ContextCollectionKey).(*models.Collection)
|
||||
if collection == nil {
|
||||
return NewNotFoundError("Missing collection context.", nil)
|
||||
}
|
||||
|
||||
form := forms.NewRecordVerificationConfirm(api.app, collection)
|
||||
if readErr := c.Bind(form); readErr != nil {
|
||||
return NewBadRequestError("An error occurred while loading the submitted data.", readErr)
|
||||
}
|
||||
|
||||
event := new(core.RecordConfirmVerificationEvent)
|
||||
event.HttpContext = c
|
||||
event.Collection = collection
|
||||
|
||||
_, submitErr := form.Submit(func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
|
||||
return func(record *models.Record) error {
|
||||
event.Record = record
|
||||
|
||||
return api.app.OnRecordBeforeConfirmVerificationRequest().Trigger(event, func(e *core.RecordConfirmVerificationEvent) error {
|
||||
if err := next(e.Record); err != nil {
|
||||
return NewBadRequestError("An error occurred while submitting the form.", err)
|
||||
}
|
||||
|
||||
return e.HttpContext.NoContent(http.StatusNoContent)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if submitErr == nil {
|
||||
if err := api.app.OnRecordAfterConfirmVerificationRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return submitErr
|
||||
}
|
||||
|
||||
func (api *recordAuthApi) requestEmailChange(c echo.Context) error {
|
||||
collection, _ := c.Get(ContextCollectionKey).(*models.Collection)
|
||||
if collection == nil {
|
||||
return NewNotFoundError("Missing collection context.", nil)
|
||||
}
|
||||
|
||||
record, _ := c.Get(ContextAuthRecordKey).(*models.Record)
|
||||
if record == nil {
|
||||
return NewUnauthorizedError("The request requires valid auth record.", nil)
|
||||
}
|
||||
|
||||
form := forms.NewRecordEmailChangeRequest(api.app, record)
|
||||
if err := c.Bind(form); err != nil {
|
||||
return NewBadRequestError("An error occurred while loading the submitted data.", err)
|
||||
}
|
||||
|
||||
event := new(core.RecordRequestEmailChangeEvent)
|
||||
event.HttpContext = c
|
||||
event.Collection = collection
|
||||
event.Record = record
|
||||
|
||||
submitErr := form.Submit(func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
|
||||
return func(record *models.Record) error {
|
||||
return api.app.OnRecordBeforeRequestEmailChangeRequest().Trigger(event, func(e *core.RecordRequestEmailChangeEvent) error {
|
||||
if err := next(e.Record); err != nil {
|
||||
return NewBadRequestError("Failed to request email change.", err)
|
||||
}
|
||||
|
||||
return e.HttpContext.NoContent(http.StatusNoContent)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if submitErr == nil {
|
||||
api.app.OnRecordAfterRequestEmailChangeRequest().Trigger(event)
|
||||
}
|
||||
|
||||
return submitErr
|
||||
}
|
||||
|
||||
func (api *recordAuthApi) confirmEmailChange(c echo.Context) error {
|
||||
collection, _ := c.Get(ContextCollectionKey).(*models.Collection)
|
||||
if collection == nil {
|
||||
return NewNotFoundError("Missing collection context.", nil)
|
||||
}
|
||||
|
||||
form := forms.NewRecordEmailChangeConfirm(api.app, collection)
|
||||
if readErr := c.Bind(form); readErr != nil {
|
||||
return NewBadRequestError("An error occurred while loading the submitted data.", readErr)
|
||||
}
|
||||
|
||||
event := new(core.RecordConfirmEmailChangeEvent)
|
||||
event.HttpContext = c
|
||||
event.Collection = collection
|
||||
|
||||
_, submitErr := form.Submit(func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
|
||||
return func(record *models.Record) error {
|
||||
event.Record = record
|
||||
|
||||
return api.app.OnRecordBeforeConfirmEmailChangeRequest().Trigger(event, func(e *core.RecordConfirmEmailChangeEvent) error {
|
||||
if err := next(e.Record); err != nil {
|
||||
return NewBadRequestError("Failed to confirm email change.", err)
|
||||
}
|
||||
|
||||
return e.HttpContext.NoContent(http.StatusNoContent)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if submitErr == nil {
|
||||
if err := api.app.OnRecordAfterConfirmEmailChangeRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return submitErr
|
||||
}
|
||||
|
||||
func (api *recordAuthApi) listExternalAuths(c echo.Context) error {
|
||||
collection, _ := c.Get(ContextCollectionKey).(*models.Collection)
|
||||
if collection == nil {
|
||||
return NewNotFoundError("Missing collection context.", nil)
|
||||
}
|
||||
|
||||
id := c.PathParam("id")
|
||||
if id == "" {
|
||||
return NewNotFoundError("", nil)
|
||||
}
|
||||
|
||||
record, err := api.app.Dao().FindRecordById(collection.Id, id)
|
||||
if err != nil || record == nil {
|
||||
return NewNotFoundError("", err)
|
||||
}
|
||||
|
||||
externalAuths, err := api.app.Dao().FindAllExternalAuthsByRecord(record)
|
||||
if err != nil {
|
||||
return NewBadRequestError("Failed to fetch the external auths for the specified auth record.", err)
|
||||
}
|
||||
|
||||
event := new(core.RecordListExternalAuthsEvent)
|
||||
event.HttpContext = c
|
||||
event.Collection = collection
|
||||
event.Record = record
|
||||
event.ExternalAuths = externalAuths
|
||||
|
||||
return api.app.OnRecordListExternalAuthsRequest().Trigger(event, func(e *core.RecordListExternalAuthsEvent) error {
|
||||
return e.HttpContext.JSON(http.StatusOK, e.ExternalAuths)
|
||||
})
|
||||
}
|
||||
|
||||
func (api *recordAuthApi) unlinkExternalAuth(c echo.Context) error {
|
||||
collection, _ := c.Get(ContextCollectionKey).(*models.Collection)
|
||||
if collection == nil {
|
||||
return NewNotFoundError("Missing collection context.", nil)
|
||||
}
|
||||
|
||||
id := c.PathParam("id")
|
||||
provider := c.PathParam("provider")
|
||||
if id == "" || provider == "" {
|
||||
return NewNotFoundError("", nil)
|
||||
}
|
||||
|
||||
record, err := api.app.Dao().FindRecordById(collection.Id, id)
|
||||
if err != nil || record == nil {
|
||||
return NewNotFoundError("", err)
|
||||
}
|
||||
|
||||
externalAuth, err := api.app.Dao().FindExternalAuthByRecordAndProvider(record, provider)
|
||||
if err != nil {
|
||||
return NewNotFoundError("Missing external auth provider relation.", err)
|
||||
}
|
||||
|
||||
event := new(core.RecordUnlinkExternalAuthEvent)
|
||||
event.HttpContext = c
|
||||
event.Collection = collection
|
||||
event.Record = record
|
||||
event.ExternalAuth = externalAuth
|
||||
|
||||
handlerErr := api.app.OnRecordBeforeUnlinkExternalAuthRequest().Trigger(event, func(e *core.RecordUnlinkExternalAuthEvent) error {
|
||||
if err := api.app.Dao().DeleteExternalAuth(externalAuth); err != nil {
|
||||
return NewBadRequestError("Cannot unlink the external auth provider.", err)
|
||||
}
|
||||
|
||||
return e.HttpContext.NoContent(http.StatusNoContent)
|
||||
})
|
||||
|
||||
if handlerErr == nil {
|
||||
api.app.OnRecordAfterUnlinkExternalAuthRequest().Trigger(event)
|
||||
}
|
||||
|
||||
return handlerErr
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,416 @@
|
||||
package apis
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
"github.com/pocketbase/pocketbase/forms"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/resolvers"
|
||||
"github.com/pocketbase/pocketbase/tools/search"
|
||||
)
|
||||
|
||||
const expandQueryParam = "expand"
|
||||
|
||||
// bindRecordCrudApi registers the record crud api endpoints and
|
||||
// the corresponding handlers.
|
||||
func bindRecordCrudApi(app core.App, rg *echo.Group) {
|
||||
api := recordApi{app: app}
|
||||
|
||||
subGroup := rg.Group(
|
||||
"/collections/:collection",
|
||||
ActivityLogger(app),
|
||||
LoadCollectionContext(app),
|
||||
)
|
||||
|
||||
subGroup.GET("/records", api.list)
|
||||
subGroup.POST("/records", api.create)
|
||||
subGroup.GET("/records/:id", api.view)
|
||||
subGroup.PATCH("/records/:id", api.update)
|
||||
subGroup.DELETE("/records/:id", api.delete)
|
||||
}
|
||||
|
||||
type recordApi struct {
|
||||
app core.App
|
||||
}
|
||||
|
||||
func (api *recordApi) list(c echo.Context) error {
|
||||
collection, _ := c.Get(ContextCollectionKey).(*models.Collection)
|
||||
if collection == nil {
|
||||
return NewNotFoundError("", "Missing collection context.")
|
||||
}
|
||||
|
||||
// forbid users and guests to query special filter/sort fields
|
||||
if err := api.checkForForbiddenQueryFields(c); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
requestData := RequestData(c)
|
||||
|
||||
if requestData.Admin == nil && collection.ListRule == nil {
|
||||
// only admins can access if the rule is nil
|
||||
return NewForbiddenError("Only admins can perform this action.", nil)
|
||||
}
|
||||
|
||||
fieldsResolver := resolvers.NewRecordFieldResolver(
|
||||
api.app.Dao(),
|
||||
collection,
|
||||
requestData,
|
||||
// hidden fields are searchable only by admins
|
||||
requestData.Admin != nil,
|
||||
)
|
||||
|
||||
searchProvider := search.NewProvider(fieldsResolver).
|
||||
Query(api.app.Dao().RecordQuery(collection))
|
||||
|
||||
if requestData.Admin == nil && collection.ListRule != nil {
|
||||
searchProvider.AddFilter(search.FilterData(*collection.ListRule))
|
||||
}
|
||||
|
||||
var rawRecords = []dbx.NullStringMap{}
|
||||
result, err := searchProvider.ParseAndExec(c.QueryParams().Encode(), &rawRecords)
|
||||
if err != nil {
|
||||
return NewBadRequestError("Invalid filter parameters.", err)
|
||||
}
|
||||
|
||||
records := models.NewRecordsFromNullStringMaps(collection, rawRecords)
|
||||
|
||||
result.Items = records
|
||||
|
||||
event := new(core.RecordsListEvent)
|
||||
event.HttpContext = c
|
||||
event.Collection = collection
|
||||
event.Records = records
|
||||
event.Result = result
|
||||
|
||||
return api.app.OnRecordsListRequest().Trigger(event, func(e *core.RecordsListEvent) error {
|
||||
if err := EnrichRecords(e.HttpContext, api.app.Dao(), e.Records); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
|
||||
return e.HttpContext.JSON(http.StatusOK, e.Result)
|
||||
})
|
||||
}
|
||||
|
||||
func (api *recordApi) view(c echo.Context) error {
|
||||
collection, _ := c.Get(ContextCollectionKey).(*models.Collection)
|
||||
if collection == nil {
|
||||
return NewNotFoundError("", "Missing collection context.")
|
||||
}
|
||||
|
||||
recordId := c.PathParam("id")
|
||||
if recordId == "" {
|
||||
return NewNotFoundError("", nil)
|
||||
}
|
||||
|
||||
requestData := RequestData(c)
|
||||
|
||||
if requestData.Admin == nil && collection.ViewRule == nil {
|
||||
// only admins can access if the rule is nil
|
||||
return NewForbiddenError("Only admins can perform this action.", nil)
|
||||
}
|
||||
|
||||
ruleFunc := func(q *dbx.SelectQuery) error {
|
||||
if requestData.Admin == nil && collection.ViewRule != nil && *collection.ViewRule != "" {
|
||||
resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), collection, requestData, true)
|
||||
expr, err := search.FilterData(*collection.ViewRule).BuildExpr(resolver)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resolver.UpdateQuery(q)
|
||||
q.AndWhere(expr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
record, fetchErr := api.app.Dao().FindRecordById(collection.Id, recordId, ruleFunc)
|
||||
if fetchErr != nil || record == nil {
|
||||
return NewNotFoundError("", fetchErr)
|
||||
}
|
||||
|
||||
event := new(core.RecordViewEvent)
|
||||
event.HttpContext = c
|
||||
event.Collection = collection
|
||||
event.Record = record
|
||||
|
||||
return api.app.OnRecordViewRequest().Trigger(event, func(e *core.RecordViewEvent) error {
|
||||
if err := EnrichRecord(e.HttpContext, api.app.Dao(), e.Record); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
|
||||
return e.HttpContext.JSON(http.StatusOK, e.Record)
|
||||
})
|
||||
}
|
||||
|
||||
func (api *recordApi) create(c echo.Context) error {
|
||||
collection, _ := c.Get(ContextCollectionKey).(*models.Collection)
|
||||
if collection == nil {
|
||||
return NewNotFoundError("", "Missing collection context.")
|
||||
}
|
||||
|
||||
requestData := RequestData(c)
|
||||
|
||||
if requestData.Admin == nil && collection.CreateRule == nil {
|
||||
// only admins can access if the rule is nil
|
||||
return NewForbiddenError("Only admins can perform this action.", nil)
|
||||
}
|
||||
|
||||
hasFullManageAccess := requestData.Admin != nil
|
||||
|
||||
// temporary save the record and check it against the create rule
|
||||
if requestData.Admin == nil && collection.CreateRule != nil {
|
||||
testRecord := models.NewRecord(collection)
|
||||
|
||||
// replace modifiers fields so that the resolved value is always
|
||||
// available when accessing requestData.Data using just the field name
|
||||
if requestData.HasModifierDataKeys() {
|
||||
requestData.Data = testRecord.ReplaceModifers(requestData.Data)
|
||||
}
|
||||
|
||||
testForm := forms.NewRecordUpsert(api.app, testRecord)
|
||||
testForm.SetFullManageAccess(true)
|
||||
if err := testForm.LoadRequest(c.Request(), ""); err != nil {
|
||||
return NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
|
||||
}
|
||||
|
||||
createRuleFunc := func(q *dbx.SelectQuery) error {
|
||||
if *collection.CreateRule == "" {
|
||||
return nil // no create rule to resolve
|
||||
}
|
||||
|
||||
resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), collection, requestData, true)
|
||||
expr, err := search.FilterData(*collection.CreateRule).BuildExpr(resolver)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resolver.UpdateQuery(q)
|
||||
q.AndWhere(expr)
|
||||
return nil
|
||||
}
|
||||
|
||||
testErr := testForm.DrySubmit(func(txDao *daos.Dao) error {
|
||||
foundRecord, err := txDao.FindRecordById(collection.Id, testRecord.Id, createRuleFunc)
|
||||
if err != nil {
|
||||
return fmt.Errorf("DrySubmit create rule failure: %w", err)
|
||||
}
|
||||
hasFullManageAccess = hasAuthManageAccess(txDao, foundRecord, requestData)
|
||||
return nil
|
||||
})
|
||||
|
||||
if testErr != nil {
|
||||
return NewBadRequestError("Failed to create record.", testErr)
|
||||
}
|
||||
}
|
||||
|
||||
record := models.NewRecord(collection)
|
||||
form := forms.NewRecordUpsert(api.app, record)
|
||||
form.SetFullManageAccess(hasFullManageAccess)
|
||||
|
||||
// load request
|
||||
if err := form.LoadRequest(c.Request(), ""); err != nil {
|
||||
return NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
|
||||
}
|
||||
|
||||
event := new(core.RecordCreateEvent)
|
||||
event.HttpContext = c
|
||||
event.Collection = collection
|
||||
event.Record = record
|
||||
|
||||
// create the record
|
||||
submitErr := form.Submit(func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
|
||||
return func(m *models.Record) error {
|
||||
event.Record = m
|
||||
|
||||
return api.app.OnRecordBeforeCreateRequest().Trigger(event, func(e *core.RecordCreateEvent) error {
|
||||
if err := next(e.Record); err != nil {
|
||||
return NewBadRequestError("Failed to create record.", err)
|
||||
}
|
||||
|
||||
if err := EnrichRecord(e.HttpContext, api.app.Dao(), e.Record); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
|
||||
return e.HttpContext.JSON(http.StatusOK, e.Record)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if submitErr == nil {
|
||||
if err := api.app.OnRecordAfterCreateRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return submitErr
|
||||
}
|
||||
|
||||
func (api *recordApi) update(c echo.Context) error {
|
||||
collection, _ := c.Get(ContextCollectionKey).(*models.Collection)
|
||||
if collection == nil {
|
||||
return NewNotFoundError("", "Missing collection context.")
|
||||
}
|
||||
|
||||
recordId := c.PathParam("id")
|
||||
if recordId == "" {
|
||||
return NewNotFoundError("", nil)
|
||||
}
|
||||
|
||||
requestData := RequestData(c)
|
||||
|
||||
if requestData.Admin == nil && collection.UpdateRule == nil {
|
||||
// only admins can access if the rule is nil
|
||||
return NewForbiddenError("Only admins can perform this action.", nil)
|
||||
}
|
||||
|
||||
// eager fetch the record so that the modifier field values are replaced
|
||||
// and available when accessing requestData.Data using just the field name
|
||||
if requestData.HasModifierDataKeys() {
|
||||
record, err := api.app.Dao().FindRecordById(collection.Id, recordId)
|
||||
if err != nil || record == nil {
|
||||
return NewNotFoundError("", err)
|
||||
}
|
||||
requestData.Data = record.ReplaceModifers(requestData.Data)
|
||||
}
|
||||
|
||||
ruleFunc := func(q *dbx.SelectQuery) error {
|
||||
if requestData.Admin == nil && collection.UpdateRule != nil && *collection.UpdateRule != "" {
|
||||
resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), collection, requestData, true)
|
||||
expr, err := search.FilterData(*collection.UpdateRule).BuildExpr(resolver)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resolver.UpdateQuery(q)
|
||||
q.AndWhere(expr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// fetch record
|
||||
record, fetchErr := api.app.Dao().FindRecordById(collection.Id, recordId, ruleFunc)
|
||||
if fetchErr != nil || record == nil {
|
||||
return NewNotFoundError("", fetchErr)
|
||||
}
|
||||
|
||||
form := forms.NewRecordUpsert(api.app, record)
|
||||
form.SetFullManageAccess(requestData.Admin != nil || hasAuthManageAccess(api.app.Dao(), record, requestData))
|
||||
|
||||
// load request
|
||||
if err := form.LoadRequest(c.Request(), ""); err != nil {
|
||||
return NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
|
||||
}
|
||||
|
||||
event := new(core.RecordUpdateEvent)
|
||||
event.HttpContext = c
|
||||
event.Collection = collection
|
||||
event.Record = record
|
||||
|
||||
// update the record
|
||||
submitErr := form.Submit(func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
|
||||
return func(m *models.Record) error {
|
||||
event.Record = m
|
||||
|
||||
return api.app.OnRecordBeforeUpdateRequest().Trigger(event, func(e *core.RecordUpdateEvent) error {
|
||||
if err := next(e.Record); err != nil {
|
||||
return NewBadRequestError("Failed to update record.", err)
|
||||
}
|
||||
|
||||
if err := EnrichRecord(e.HttpContext, api.app.Dao(), e.Record); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
|
||||
return e.HttpContext.JSON(http.StatusOK, e.Record)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if submitErr == nil {
|
||||
if err := api.app.OnRecordAfterUpdateRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return submitErr
|
||||
}
|
||||
|
||||
func (api *recordApi) delete(c echo.Context) error {
|
||||
collection, _ := c.Get(ContextCollectionKey).(*models.Collection)
|
||||
if collection == nil {
|
||||
return NewNotFoundError("", "Missing collection context.")
|
||||
}
|
||||
|
||||
recordId := c.PathParam("id")
|
||||
if recordId == "" {
|
||||
return NewNotFoundError("", nil)
|
||||
}
|
||||
|
||||
requestData := RequestData(c)
|
||||
|
||||
if requestData.Admin == nil && collection.DeleteRule == nil {
|
||||
// only admins can access if the rule is nil
|
||||
return NewForbiddenError("Only admins can perform this action.", nil)
|
||||
}
|
||||
|
||||
ruleFunc := func(q *dbx.SelectQuery) error {
|
||||
if requestData.Admin == nil && collection.DeleteRule != nil && *collection.DeleteRule != "" {
|
||||
resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), collection, requestData, true)
|
||||
expr, err := search.FilterData(*collection.DeleteRule).BuildExpr(resolver)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resolver.UpdateQuery(q)
|
||||
q.AndWhere(expr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
record, fetchErr := api.app.Dao().FindRecordById(collection.Id, recordId, ruleFunc)
|
||||
if fetchErr != nil || record == nil {
|
||||
return NewNotFoundError("", fetchErr)
|
||||
}
|
||||
|
||||
event := new(core.RecordDeleteEvent)
|
||||
event.HttpContext = c
|
||||
event.Collection = collection
|
||||
event.Record = record
|
||||
|
||||
handlerErr := api.app.OnRecordBeforeDeleteRequest().Trigger(event, func(e *core.RecordDeleteEvent) error {
|
||||
// delete the record
|
||||
if err := api.app.Dao().DeleteRecord(e.Record); err != nil {
|
||||
return NewBadRequestError("Failed to delete record. Make sure that the record is not part of a required relation reference.", err)
|
||||
}
|
||||
|
||||
return e.HttpContext.NoContent(http.StatusNoContent)
|
||||
})
|
||||
|
||||
if handlerErr == nil {
|
||||
if err := api.app.OnRecordAfterDeleteRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return handlerErr
|
||||
}
|
||||
|
||||
func (api *recordApi) checkForForbiddenQueryFields(c echo.Context) error {
|
||||
admin, _ := c.Get(ContextAdminKey).(*models.Admin)
|
||||
if admin != nil {
|
||||
return nil // admins are allowed to query everything
|
||||
}
|
||||
|
||||
decodedQuery := c.QueryParam(search.FilterQueryParam) + c.QueryParam(search.SortQueryParam)
|
||||
forbiddenFields := []string{"@collection.", "@request."}
|
||||
|
||||
for _, field := range forbiddenFields {
|
||||
if strings.Contains(decodedQuery, field) {
|
||||
return NewForbiddenError("Only admins can filter by @collection and @request query params", nil)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,269 @@
|
||||
package apis
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/resolvers"
|
||||
"github.com/pocketbase/pocketbase/tokens"
|
||||
"github.com/pocketbase/pocketbase/tools/rest"
|
||||
"github.com/pocketbase/pocketbase/tools/search"
|
||||
)
|
||||
|
||||
const ContextRequestDataKey = "requestData"
|
||||
|
||||
// RequestData exports cached common request data fields
|
||||
// (query, body, logged auth state, etc.) from the provided context.
|
||||
func RequestData(c echo.Context) *models.RequestData {
|
||||
// return cached to avoid copying the body multiple times
|
||||
if v := c.Get(ContextRequestDataKey); v != nil {
|
||||
if data, ok := v.(*models.RequestData); ok {
|
||||
return data
|
||||
}
|
||||
}
|
||||
|
||||
result := &models.RequestData{
|
||||
Method: c.Request().Method,
|
||||
Query: map[string]any{},
|
||||
Data: map[string]any{},
|
||||
}
|
||||
|
||||
result.AuthRecord, _ = c.Get(ContextAuthRecordKey).(*models.Record)
|
||||
result.Admin, _ = c.Get(ContextAdminKey).(*models.Admin)
|
||||
echo.BindQueryParams(c, &result.Query)
|
||||
rest.BindBody(c, &result.Data)
|
||||
|
||||
c.Set(ContextRequestDataKey, result)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func RecordAuthResponse(app core.App, c echo.Context, authRecord *models.Record, meta any) error {
|
||||
token, tokenErr := tokens.NewRecordAuthToken(app, authRecord)
|
||||
if tokenErr != nil {
|
||||
return NewBadRequestError("Failed to create auth token.", tokenErr)
|
||||
}
|
||||
|
||||
event := new(core.RecordAuthEvent)
|
||||
event.HttpContext = c
|
||||
event.Collection = authRecord.Collection()
|
||||
event.Record = authRecord
|
||||
event.Token = token
|
||||
event.Meta = meta
|
||||
|
||||
return app.OnRecordAuthRequest().Trigger(event, func(e *core.RecordAuthEvent) error {
|
||||
// allow always returning the email address of the authenticated account
|
||||
e.Record.IgnoreEmailVisibility(true)
|
||||
|
||||
// expand record relations
|
||||
expands := strings.Split(c.QueryParam(expandQueryParam), ",")
|
||||
if len(expands) > 0 {
|
||||
// create a copy of the cached request data and adjust it to the current auth record
|
||||
requestData := *RequestData(e.HttpContext)
|
||||
requestData.Admin = nil
|
||||
requestData.AuthRecord = e.Record
|
||||
failed := app.Dao().ExpandRecord(
|
||||
e.Record,
|
||||
expands,
|
||||
expandFetch(app.Dao(), &requestData),
|
||||
)
|
||||
if len(failed) > 0 && app.IsDebug() {
|
||||
log.Println("Failed to expand relations: ", failed)
|
||||
}
|
||||
}
|
||||
|
||||
result := map[string]any{
|
||||
"token": e.Token,
|
||||
"record": e.Record,
|
||||
}
|
||||
|
||||
if e.Meta != nil {
|
||||
result["meta"] = e.Meta
|
||||
}
|
||||
|
||||
return e.HttpContext.JSON(http.StatusOK, result)
|
||||
})
|
||||
}
|
||||
|
||||
// EnrichRecord parses the request context and enrich the provided record:
|
||||
// - expands relations (if defaultExpands and/or ?expand query param is set)
|
||||
// - ensures that the emails of the auth record and its expanded auth relations
|
||||
// are visibe only for the current logged admin, record owner or record with manage access
|
||||
func EnrichRecord(c echo.Context, dao *daos.Dao, record *models.Record, defaultExpands ...string) error {
|
||||
return EnrichRecords(c, dao, []*models.Record{record}, defaultExpands...)
|
||||
}
|
||||
|
||||
// EnrichRecords parses the request context and enriches the provided records:
|
||||
// - expands relations (if defaultExpands and/or ?expand query param is set)
|
||||
// - ensures that the emails of the auth records and their expanded auth relations
|
||||
// are visibe only for the current logged admin, record owner or record with manage access
|
||||
func EnrichRecords(c echo.Context, dao *daos.Dao, records []*models.Record, defaultExpands ...string) error {
|
||||
requestData := RequestData(c)
|
||||
|
||||
if err := autoIgnoreAuthRecordsEmailVisibility(dao, records, requestData); err != nil {
|
||||
return fmt.Errorf("Failed to resolve email visibility: %w", err)
|
||||
}
|
||||
|
||||
expands := defaultExpands
|
||||
expands = append(expands, strings.Split(c.QueryParam(expandQueryParam), ",")...)
|
||||
if len(expands) == 0 {
|
||||
return nil // nothing to expand
|
||||
}
|
||||
|
||||
errs := dao.ExpandRecords(records, expands, expandFetch(dao, requestData))
|
||||
if len(errs) > 0 {
|
||||
return fmt.Errorf("Failed to expand: %v", errs)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// expandFetch is the records fetch function that is used to expand related records.
|
||||
func expandFetch(
|
||||
dao *daos.Dao,
|
||||
requestData *models.RequestData,
|
||||
) daos.ExpandFetchFunc {
|
||||
return func(relCollection *models.Collection, relIds []string) ([]*models.Record, error) {
|
||||
records, err := dao.FindRecordsByIds(relCollection.Id, relIds, func(q *dbx.SelectQuery) error {
|
||||
if requestData.Admin != nil {
|
||||
return nil // admins can access everything
|
||||
}
|
||||
|
||||
if relCollection.ViewRule == nil {
|
||||
return fmt.Errorf("Only admins can view collection %q records", relCollection.Name)
|
||||
}
|
||||
|
||||
if *relCollection.ViewRule != "" {
|
||||
resolver := resolvers.NewRecordFieldResolver(dao, relCollection, requestData, true)
|
||||
expr, err := search.FilterData(*(relCollection.ViewRule)).BuildExpr(resolver)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resolver.UpdateQuery(q)
|
||||
q.AndWhere(expr)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if err == nil && len(records) > 0 {
|
||||
autoIgnoreAuthRecordsEmailVisibility(dao, records, requestData)
|
||||
}
|
||||
|
||||
return records, err
|
||||
}
|
||||
}
|
||||
|
||||
// autoIgnoreAuthRecordsEmailVisibility ignores the email visibility check for
|
||||
// the provided record if the current auth model is admin, owner or a "manager".
|
||||
//
|
||||
// Note: Expects all records to be from the same auth collection!
|
||||
func autoIgnoreAuthRecordsEmailVisibility(
|
||||
dao *daos.Dao,
|
||||
records []*models.Record,
|
||||
requestData *models.RequestData,
|
||||
) error {
|
||||
if len(records) == 0 || !records[0].Collection().IsAuth() {
|
||||
return nil // nothing to check
|
||||
}
|
||||
|
||||
if requestData.Admin != nil {
|
||||
for _, rec := range records {
|
||||
rec.IgnoreEmailVisibility(true)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
collection := records[0].Collection()
|
||||
|
||||
mappedRecords := make(map[string]*models.Record, len(records))
|
||||
recordIds := make([]any, len(records))
|
||||
for i, rec := range records {
|
||||
mappedRecords[rec.Id] = rec
|
||||
recordIds[i] = rec.Id
|
||||
}
|
||||
|
||||
if requestData != nil && requestData.AuthRecord != nil && mappedRecords[requestData.AuthRecord.Id] != nil {
|
||||
mappedRecords[requestData.AuthRecord.Id].IgnoreEmailVisibility(true)
|
||||
}
|
||||
|
||||
authOptions := collection.AuthOptions()
|
||||
if authOptions.ManageRule == nil || *authOptions.ManageRule == "" {
|
||||
return nil // no manage rule to check
|
||||
}
|
||||
|
||||
// fetch the ids of the managed records
|
||||
// ---
|
||||
managedIds := []string{}
|
||||
|
||||
query := dao.RecordQuery(collection).
|
||||
Select(dao.DB().QuoteSimpleColumnName(collection.Name) + ".id").
|
||||
AndWhere(dbx.In(dao.DB().QuoteSimpleColumnName(collection.Name)+".id", recordIds...))
|
||||
|
||||
resolver := resolvers.NewRecordFieldResolver(dao, collection, requestData, true)
|
||||
expr, err := search.FilterData(*authOptions.ManageRule).BuildExpr(resolver)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resolver.UpdateQuery(query)
|
||||
query.AndWhere(expr)
|
||||
|
||||
if err := query.Column(&managedIds); err != nil {
|
||||
return err
|
||||
}
|
||||
// ---
|
||||
|
||||
// ignore the email visibility check for the managed records
|
||||
for _, id := range managedIds {
|
||||
if rec, ok := mappedRecords[id]; ok {
|
||||
rec.IgnoreEmailVisibility(true)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// hasAuthManageAccess checks whether the client is allowed to have full
|
||||
// [forms.RecordUpsert] auth management permissions
|
||||
// (aka. allowing to change system auth fields without oldPassword).
|
||||
func hasAuthManageAccess(
|
||||
dao *daos.Dao,
|
||||
record *models.Record,
|
||||
requestData *models.RequestData,
|
||||
) bool {
|
||||
if !record.Collection().IsAuth() {
|
||||
return false
|
||||
}
|
||||
|
||||
manageRule := record.Collection().AuthOptions().ManageRule
|
||||
|
||||
if manageRule == nil || *manageRule == "" {
|
||||
return false // only for admins (manageRule can't be empty)
|
||||
}
|
||||
|
||||
if requestData == nil || requestData.AuthRecord == nil {
|
||||
return false // no auth record
|
||||
}
|
||||
|
||||
ruleFunc := func(q *dbx.SelectQuery) error {
|
||||
resolver := resolvers.NewRecordFieldResolver(dao, record.Collection(), requestData, true)
|
||||
expr, err := search.FilterData(*manageRule).BuildExpr(resolver)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resolver.UpdateQuery(q)
|
||||
q.AndWhere(expr)
|
||||
return nil
|
||||
}
|
||||
|
||||
_, findErr := dao.FindRecordById(record.Collection().Id, record.Id, ruleFunc)
|
||||
|
||||
return findErr == nil
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
package apis_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/pocketbase/pocketbase/apis"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
)
|
||||
|
||||
func TestRequestData(t *testing.T) {
|
||||
e := echo.New()
|
||||
req := httptest.NewRequest(http.MethodPost, "/?test=123", strings.NewReader(`{"test":456}`))
|
||||
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
|
||||
rec := httptest.NewRecorder()
|
||||
c := e.NewContext(req, rec)
|
||||
|
||||
dummyRecord := &models.Record{}
|
||||
dummyRecord.Id = "id1"
|
||||
c.Set(apis.ContextAuthRecordKey, dummyRecord)
|
||||
|
||||
dummyAdmin := &models.Admin{}
|
||||
dummyAdmin.Id = "id2"
|
||||
c.Set(apis.ContextAdminKey, dummyAdmin)
|
||||
|
||||
result := apis.RequestData(c)
|
||||
|
||||
if result == nil {
|
||||
t.Fatal("Expected *models.RequestData instance, got nil")
|
||||
}
|
||||
|
||||
if result.Method != http.MethodPost {
|
||||
t.Fatalf("Expected Method %v, got %v", http.MethodPost, result.Method)
|
||||
}
|
||||
|
||||
rawQuery, _ := json.Marshal(result.Query)
|
||||
expectedQuery := `{"test":"123"}`
|
||||
if v := string(rawQuery); v != expectedQuery {
|
||||
t.Fatalf("Expected Query %v, got %v", expectedQuery, v)
|
||||
}
|
||||
|
||||
rawData, _ := json.Marshal(result.Data)
|
||||
expectedData := `{"test":456}`
|
||||
if v := string(rawData); v != expectedData {
|
||||
t.Fatalf("Expected Data %v, got %v", expectedData, v)
|
||||
}
|
||||
|
||||
if result.AuthRecord == nil || result.AuthRecord.Id != dummyRecord.Id {
|
||||
t.Fatalf("Expected AuthRecord %v, got %v", dummyRecord, result.AuthRecord)
|
||||
}
|
||||
|
||||
if result.Admin == nil || result.Admin.Id != dummyAdmin.Id {
|
||||
t.Fatalf("Expected Admin %v, got %v", dummyAdmin, result.Admin)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordAuthResponse(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
dummyAdmin := &models.Admin{}
|
||||
dummyAdmin.Id = "id1"
|
||||
|
||||
nonAuthRecord, err := app.Dao().FindRecordById("demo1", "al1h9ijdeojtsjy")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
authRecord, err := app.Dao().FindRecordById("users", "4q1xlclmfloku33")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
record *models.Record
|
||||
meta any
|
||||
expectError bool
|
||||
expectedContent []string
|
||||
notExpectedContent []string
|
||||
expectedEvents map[string]int
|
||||
}{
|
||||
{
|
||||
name: "non auth record",
|
||||
record: nonAuthRecord,
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "valid auth record - without meta",
|
||||
record: authRecord,
|
||||
expectError: false,
|
||||
expectedContent: []string{
|
||||
`"token":"`,
|
||||
`"record":{`,
|
||||
`"id":"`,
|
||||
`"expand":{"rel":{`,
|
||||
},
|
||||
notExpectedContent: []string{
|
||||
`"meta":`,
|
||||
},
|
||||
expectedEvents: map[string]int{
|
||||
"OnRecordAuthRequest": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "valid auth record - with meta",
|
||||
record: authRecord,
|
||||
meta: map[string]any{"meta_test": 123},
|
||||
expectError: false,
|
||||
expectedContent: []string{
|
||||
`"token":"`,
|
||||
`"record":{`,
|
||||
`"id":"`,
|
||||
`"expand":{"rel":{`,
|
||||
`"meta":{"meta_test":123`,
|
||||
},
|
||||
expectedEvents: map[string]int{
|
||||
"OnRecordAuthRequest": 1,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
app.ResetEventCalls()
|
||||
|
||||
e := echo.New()
|
||||
req := httptest.NewRequest(http.MethodGet, "/?expand=rel", nil)
|
||||
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
|
||||
rec := httptest.NewRecorder()
|
||||
c := e.NewContext(req, rec)
|
||||
c.Set(apis.ContextAdminKey, dummyAdmin)
|
||||
|
||||
responseErr := apis.RecordAuthResponse(app, c, s.record, s.meta)
|
||||
|
||||
hasErr := responseErr != nil
|
||||
if hasErr != s.expectError {
|
||||
t.Fatalf("[%s] Expected hasErr to be %v, got %v (%v)", s.name, s.expectError, hasErr, responseErr)
|
||||
}
|
||||
|
||||
if len(app.EventCalls) != len(s.expectedEvents) {
|
||||
t.Fatalf("[%s] Expected events \n%v, \ngot \n%v", s.name, s.expectedEvents, app.EventCalls)
|
||||
}
|
||||
for k, v := range s.expectedEvents {
|
||||
if app.EventCalls[k] != v {
|
||||
t.Fatalf("[%s] Expected event %s to be called %d times, got %d", s.name, k, v, app.EventCalls[k])
|
||||
}
|
||||
}
|
||||
|
||||
if hasErr {
|
||||
continue
|
||||
}
|
||||
|
||||
response := rec.Body.String()
|
||||
|
||||
for _, v := range s.expectedContent {
|
||||
if !strings.Contains(response, v) {
|
||||
t.Fatalf("[%s] Missing %v in response \n%v", s.name, v, response)
|
||||
}
|
||||
}
|
||||
|
||||
for _, v := range s.notExpectedContent {
|
||||
if strings.Contains(response, v) {
|
||||
t.Fatalf("[%s] Unexpected %v in response \n%v", s.name, v, response)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichRecords(t *testing.T) {
|
||||
e := echo.New()
|
||||
req := httptest.NewRequest(http.MethodGet, "/?expand=rel_many", nil)
|
||||
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
|
||||
rec := httptest.NewRecorder()
|
||||
c := e.NewContext(req, rec)
|
||||
|
||||
dummyAdmin := &models.Admin{}
|
||||
dummyAdmin.Id = "test_id"
|
||||
c.Set(apis.ContextAdminKey, dummyAdmin)
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
records, err := app.Dao().FindRecordsByIds("demo1", []string{"al1h9ijdeojtsjy", "84nmscqy84lsi1t"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
apis.EnrichRecords(c, app.Dao(), records, "rel_one")
|
||||
|
||||
for _, record := range records {
|
||||
expand := record.Expand()
|
||||
if len(expand) == 0 {
|
||||
t.Fatalf("Expected non-empty expand, got nil for record %v", record)
|
||||
}
|
||||
|
||||
if len(record.GetStringSlice("rel_one")) != 0 {
|
||||
if _, ok := expand["rel_one"]; !ok {
|
||||
t.Fatalf("Expected rel_one to be expanded for record %v, got \n%v", record, expand)
|
||||
}
|
||||
}
|
||||
|
||||
if len(record.GetStringSlice("rel_many")) != 0 {
|
||||
if _, ok := expand["rel_many"]; !ok {
|
||||
t.Fatalf("Expected rel_many to be expanded for record %v, got \n%v", record, expand)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
-1052
File diff suppressed because it is too large
Load Diff
+76
-20
@@ -1,21 +1,27 @@
|
||||
package apis
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/forms"
|
||||
"github.com/pocketbase/pocketbase/tools/rest"
|
||||
"github.com/pocketbase/pocketbase/models/settings"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
)
|
||||
|
||||
// BindSettingsApi registers the settings api endpoints.
|
||||
func BindSettingsApi(app core.App, rg *echo.Group) {
|
||||
// bindSettingsApi registers the settings api endpoints.
|
||||
func bindSettingsApi(app core.App, rg *echo.Group) {
|
||||
api := settingsApi{app: app}
|
||||
|
||||
subGroup := rg.Group("/settings", ActivityLogger(app), RequireAdminAuth())
|
||||
subGroup.GET("", api.list)
|
||||
subGroup.PATCH("", api.set)
|
||||
subGroup.POST("/test/s3", api.testS3)
|
||||
subGroup.POST("/test/email", api.testEmail)
|
||||
}
|
||||
|
||||
type settingsApi struct {
|
||||
@@ -25,13 +31,12 @@ type settingsApi struct {
|
||||
func (api *settingsApi) list(c echo.Context) error {
|
||||
settings, err := api.app.Settings().RedactClone()
|
||||
if err != nil {
|
||||
return rest.NewBadRequestError("", err)
|
||||
return NewBadRequestError("", err)
|
||||
}
|
||||
|
||||
event := &core.SettingsListEvent{
|
||||
HttpContext: c,
|
||||
RedactedSettings: settings,
|
||||
}
|
||||
event := new(core.SettingsListEvent)
|
||||
event.HttpContext = c
|
||||
event.RedactedSettings = settings
|
||||
|
||||
return api.app.OnSettingsListRequest().Trigger(event, func(e *core.SettingsListEvent) error {
|
||||
return e.HttpContext.JSON(http.StatusOK, e.RedactedSettings)
|
||||
@@ -43,26 +48,26 @@ func (api *settingsApi) set(c echo.Context) error {
|
||||
|
||||
// load request
|
||||
if err := c.Bind(form); err != nil {
|
||||
return rest.NewBadRequestError("An error occurred while reading the submitted data.", err)
|
||||
return NewBadRequestError("An error occurred while loading the submitted data.", err)
|
||||
}
|
||||
|
||||
event := &core.SettingsUpdateEvent{
|
||||
HttpContext: c,
|
||||
OldSettings: api.app.Settings(),
|
||||
NewSettings: form.Settings,
|
||||
}
|
||||
event := new(core.SettingsUpdateEvent)
|
||||
event.HttpContext = c
|
||||
event.OldSettings = api.app.Settings()
|
||||
|
||||
// update the settings
|
||||
submitErr := form.Submit(func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
|
||||
return func() error {
|
||||
submitErr := form.Submit(func(next forms.InterceptorNextFunc[*settings.Settings]) forms.InterceptorNextFunc[*settings.Settings] {
|
||||
return func(s *settings.Settings) error {
|
||||
event.NewSettings = s
|
||||
|
||||
return api.app.OnSettingsBeforeUpdateRequest().Trigger(event, func(e *core.SettingsUpdateEvent) error {
|
||||
if err := next(); err != nil {
|
||||
return rest.NewBadRequestError("An error occurred while submitting the form.", err)
|
||||
if err := next(e.NewSettings); err != nil {
|
||||
return NewBadRequestError("An error occurred while submitting the form.", err)
|
||||
}
|
||||
|
||||
redactedSettings, err := api.app.Settings().RedactClone()
|
||||
if err != nil {
|
||||
return rest.NewBadRequestError("", err)
|
||||
return NewBadRequestError("", err)
|
||||
}
|
||||
|
||||
return e.HttpContext.JSON(http.StatusOK, redactedSettings)
|
||||
@@ -71,8 +76,59 @@ func (api *settingsApi) set(c echo.Context) error {
|
||||
})
|
||||
|
||||
if submitErr == nil {
|
||||
api.app.OnSettingsAfterUpdateRequest().Trigger(event)
|
||||
if err := api.app.OnSettingsAfterUpdateRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return submitErr
|
||||
}
|
||||
|
||||
func (api *settingsApi) testS3(c echo.Context) error {
|
||||
if !api.app.Settings().S3.Enabled {
|
||||
return NewBadRequestError("S3 storage is not enabled.", nil)
|
||||
}
|
||||
|
||||
fs, err := api.app.NewFilesystem()
|
||||
if err != nil {
|
||||
return NewBadRequestError("Failed to initialize the S3 storage. Raw error: \n"+err.Error(), nil)
|
||||
}
|
||||
defer fs.Close()
|
||||
|
||||
testPrefix := "pb_settings_test_" + security.PseudorandomString(5)
|
||||
testFileKey := testPrefix + "/test.txt"
|
||||
|
||||
// try to upload a test file
|
||||
if err := fs.Upload([]byte("test"), testFileKey); err != nil {
|
||||
return NewBadRequestError("Failed to upload a test file. Raw error: \n"+err.Error(), nil)
|
||||
}
|
||||
|
||||
// test prefix deletion (ensures that both bucket list and delete works)
|
||||
if errs := fs.DeletePrefix(testPrefix); len(errs) > 0 {
|
||||
return NewBadRequestError(fmt.Sprintf("Failed to delete a test file. Raw error: %v", errs), nil)
|
||||
}
|
||||
|
||||
return c.NoContent(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (api *settingsApi) testEmail(c echo.Context) error {
|
||||
form := forms.NewTestEmailSend(api.app)
|
||||
|
||||
// load request
|
||||
if err := c.Bind(form); err != nil {
|
||||
return NewBadRequestError("An error occurred while loading the submitted data.", err)
|
||||
}
|
||||
|
||||
// send
|
||||
if err := form.Submit(); err != nil {
|
||||
if fErr, ok := err.(validation.Errors); ok {
|
||||
// form error
|
||||
return NewBadRequestError("Failed to send the test email.", fErr)
|
||||
}
|
||||
|
||||
// mailer error
|
||||
return NewBadRequestError("Failed to send the test email. Raw error: \n"+err.Error(), nil)
|
||||
}
|
||||
|
||||
return c.NoContent(http.StatusNoContent)
|
||||
}
|
||||
|
||||
+245
-27
@@ -5,6 +5,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
)
|
||||
|
||||
@@ -18,11 +19,11 @@ func TestSettingsList(t *testing.T) {
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "authorized as user",
|
||||
Name: "authorized as auth record",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/settings",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
|
||||
},
|
||||
ExpectedStatus: 401,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
@@ -32,7 +33,7 @@ func TestSettingsList(t *testing.T) {
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/settings",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
@@ -42,15 +43,26 @@ func TestSettingsList(t *testing.T) {
|
||||
`"s3":{`,
|
||||
`"adminAuthToken":{`,
|
||||
`"adminPasswordResetToken":{`,
|
||||
`"userAuthToken":{`,
|
||||
`"userPasswordResetToken":{`,
|
||||
`"userEmailChangeToken":{`,
|
||||
`"userVerificationToken":{`,
|
||||
`"recordAuthToken":{`,
|
||||
`"recordPasswordResetToken":{`,
|
||||
`"recordEmailChangeToken":{`,
|
||||
`"recordVerificationToken":{`,
|
||||
`"emailAuth":{`,
|
||||
`"googleAuth":{`,
|
||||
`"facebookAuth":{`,
|
||||
`"githubAuth":{`,
|
||||
`"gitlabAuth":{`,
|
||||
`"twitterAuth":{`,
|
||||
`"discordAuth":{`,
|
||||
`"microsoftAuth":{`,
|
||||
`"spotifyAuth":{`,
|
||||
`"kakaoAuth":{`,
|
||||
`"twitchAuth":{`,
|
||||
`"stravaAuth":{`,
|
||||
`"giteeAuth":{`,
|
||||
`"livechatAuth":{`,
|
||||
`"authentikAuth":{`,
|
||||
`"giteaAuth":{`,
|
||||
`"secret":"******"`,
|
||||
`"clientSecret":"******"`,
|
||||
},
|
||||
@@ -66,7 +78,7 @@ func TestSettingsList(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSettingsSet(t *testing.T) {
|
||||
validData := `{"meta":{"appName":"update_test"},"emailAuth":{"minPasswordLength": 12}}`
|
||||
validData := `{"meta":{"appName":"update_test"}}`
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
@@ -78,12 +90,12 @@ func TestSettingsSet(t *testing.T) {
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "authorized as user",
|
||||
Name: "authorized as auth record",
|
||||
Method: http.MethodPatch,
|
||||
Url: "/api/settings",
|
||||
Body: strings.NewReader(validData),
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
|
||||
},
|
||||
ExpectedStatus: 401,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
@@ -94,7 +106,7 @@ func TestSettingsSet(t *testing.T) {
|
||||
Url: "/api/settings",
|
||||
Body: strings.NewReader(``),
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
@@ -104,19 +116,28 @@ func TestSettingsSet(t *testing.T) {
|
||||
`"s3":{`,
|
||||
`"adminAuthToken":{`,
|
||||
`"adminPasswordResetToken":{`,
|
||||
`"userAuthToken":{`,
|
||||
`"userPasswordResetToken":{`,
|
||||
`"userEmailChangeToken":{`,
|
||||
`"userVerificationToken":{`,
|
||||
`"recordAuthToken":{`,
|
||||
`"recordPasswordResetToken":{`,
|
||||
`"recordEmailChangeToken":{`,
|
||||
`"recordVerificationToken":{`,
|
||||
`"emailAuth":{`,
|
||||
`"googleAuth":{`,
|
||||
`"facebookAuth":{`,
|
||||
`"githubAuth":{`,
|
||||
`"gitlabAuth":{`,
|
||||
`"discordAuth":{`,
|
||||
`"microsoftAuth":{`,
|
||||
`"spotifyAuth":{`,
|
||||
`"kakaoAuth":{`,
|
||||
`"twitchAuth":{`,
|
||||
`"stravaAuth":{`,
|
||||
`"giteeAuth":{`,
|
||||
`"livechatAuth":{`,
|
||||
`"authentikAuth":{`,
|
||||
`"giteaAuth":{`,
|
||||
`"secret":"******"`,
|
||||
`"clientSecret":"******"`,
|
||||
`"appName":"Acme"`,
|
||||
`"minPasswordLength":8`,
|
||||
`"appName":"acme_test"`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnModelBeforeUpdate": 1,
|
||||
@@ -129,15 +150,14 @@ func TestSettingsSet(t *testing.T) {
|
||||
Name: "authorized as admin submitting invalid data",
|
||||
Method: http.MethodPatch,
|
||||
Url: "/api/settings",
|
||||
Body: strings.NewReader(`{"meta":{"appName":""},"emailAuth":{"minPasswordLength": 3}}`),
|
||||
Body: strings.NewReader(`{"meta":{"appName":""}}`),
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{
|
||||
`"data":{`,
|
||||
`"emailAuth":{"minPasswordLength":{"code":"validation_min_greater_equal_than_required","message":"Must be no less than 5."}}`,
|
||||
`"meta":{"appName":{"code":"validation_required","message":"Cannot be blank."}}`,
|
||||
`"meta":{"appName":{"code":"validation_required"`,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -146,7 +166,7 @@ func TestSettingsSet(t *testing.T) {
|
||||
Url: "/api/settings",
|
||||
Body: strings.NewReader(validData),
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
@@ -156,19 +176,29 @@ func TestSettingsSet(t *testing.T) {
|
||||
`"s3":{`,
|
||||
`"adminAuthToken":{`,
|
||||
`"adminPasswordResetToken":{`,
|
||||
`"userAuthToken":{`,
|
||||
`"userPasswordResetToken":{`,
|
||||
`"userEmailChangeToken":{`,
|
||||
`"userVerificationToken":{`,
|
||||
`"recordAuthToken":{`,
|
||||
`"recordPasswordResetToken":{`,
|
||||
`"recordEmailChangeToken":{`,
|
||||
`"recordVerificationToken":{`,
|
||||
`"emailAuth":{`,
|
||||
`"googleAuth":{`,
|
||||
`"facebookAuth":{`,
|
||||
`"githubAuth":{`,
|
||||
`"gitlabAuth":{`,
|
||||
`"twitterAuth":{`,
|
||||
`"discordAuth":{`,
|
||||
`"microsoftAuth":{`,
|
||||
`"spotifyAuth":{`,
|
||||
`"kakaoAuth":{`,
|
||||
`"twitchAuth":{`,
|
||||
`"stravaAuth":{`,
|
||||
`"giteeAuth":{`,
|
||||
`"livechatAuth":{`,
|
||||
`"authentikAuth":{`,
|
||||
`"giteaAuth":{`,
|
||||
`"secret":"******"`,
|
||||
`"clientSecret":"******"`,
|
||||
`"appName":"update_test"`,
|
||||
`"minPasswordLength":12`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnModelBeforeUpdate": 1,
|
||||
@@ -183,3 +213,191 @@ func TestSettingsSet(t *testing.T) {
|
||||
scenario.Test(t)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettingsTestS3(t *testing.T) {
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "unauthorized",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/settings/test/s3",
|
||||
ExpectedStatus: 401,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "authorized as auth record",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/settings/test/s3",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
|
||||
},
|
||||
ExpectedStatus: 401,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "authorized as admin (no s3)",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/settings/test/s3",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
scenario.Test(t)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettingsTestEmail(t *testing.T) {
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "unauthorized",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/settings/test/email",
|
||||
Body: strings.NewReader(`{
|
||||
"template": "verification",
|
||||
"email": "test@example.com"
|
||||
}`),
|
||||
ExpectedStatus: 401,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "authorized as auth record",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/settings/test/email",
|
||||
Body: strings.NewReader(`{
|
||||
"template": "verification",
|
||||
"email": "test@example.com"
|
||||
}`),
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
|
||||
},
|
||||
ExpectedStatus: 401,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "authorized as admin (invalid body)",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/settings/test/email",
|
||||
Body: strings.NewReader(`{`),
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "authorized as admin (empty json)",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/settings/test/email",
|
||||
Body: strings.NewReader(`{}`),
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{
|
||||
`"email":{"code":"validation_required"`,
|
||||
`"template":{"code":"validation_required"`,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "authorized as admin (verifiation template)",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/settings/test/email",
|
||||
Body: strings.NewReader(`{
|
||||
"template": "verification",
|
||||
"email": "test@example.com"
|
||||
}`),
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
if app.TestMailer.TotalSend != 1 {
|
||||
t.Fatalf("[verification] Expected 1 sent email, got %d", app.TestMailer.TotalSend)
|
||||
}
|
||||
|
||||
if app.TestMailer.LastMessage.To.Address != "test@example.com" {
|
||||
t.Fatalf("[verification] Expected the email to be sent to %s, got %s", "test@example.com", app.TestMailer.LastMessage.To.Address)
|
||||
}
|
||||
|
||||
if !strings.Contains(app.TestMailer.LastMessage.HTML, "Verify") {
|
||||
t.Fatalf("[verification] Expected to sent a verification email, got \n%v\n%v", app.TestMailer.LastMessage.Subject, app.TestMailer.LastMessage.HTML)
|
||||
}
|
||||
},
|
||||
ExpectedStatus: 204,
|
||||
ExpectedContent: []string{},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnMailerBeforeRecordVerificationSend": 1,
|
||||
"OnMailerAfterRecordVerificationSend": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "authorized as admin (password reset template)",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/settings/test/email",
|
||||
Body: strings.NewReader(`{
|
||||
"template": "password-reset",
|
||||
"email": "test@example.com"
|
||||
}`),
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
if app.TestMailer.TotalSend != 1 {
|
||||
t.Fatalf("[password-reset] Expected 1 sent email, got %d", app.TestMailer.TotalSend)
|
||||
}
|
||||
|
||||
if app.TestMailer.LastMessage.To.Address != "test@example.com" {
|
||||
t.Fatalf("[password-reset] Expected the email to be sent to %s, got %s", "test@example.com", app.TestMailer.LastMessage.To.Address)
|
||||
}
|
||||
|
||||
if !strings.Contains(app.TestMailer.LastMessage.HTML, "Reset password") {
|
||||
t.Fatalf("[password-reset] Expected to sent a password-reset email, got \n%v\n%v", app.TestMailer.LastMessage.Subject, app.TestMailer.LastMessage.HTML)
|
||||
}
|
||||
},
|
||||
ExpectedStatus: 204,
|
||||
ExpectedContent: []string{},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnMailerBeforeRecordResetPasswordSend": 1,
|
||||
"OnMailerAfterRecordResetPasswordSend": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "authorized as admin (email change)",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/settings/test/email",
|
||||
Body: strings.NewReader(`{
|
||||
"template": "email-change",
|
||||
"email": "test@example.com"
|
||||
}`),
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
if app.TestMailer.TotalSend != 1 {
|
||||
t.Fatalf("[email-change] Expected 1 sent email, got %d", app.TestMailer.TotalSend)
|
||||
}
|
||||
|
||||
if app.TestMailer.LastMessage.To.Address != "test@example.com" {
|
||||
t.Fatalf("[email-change] Expected the email to be sent to %s, got %s", "test@example.com", app.TestMailer.LastMessage.To.Address)
|
||||
}
|
||||
|
||||
if !strings.Contains(app.TestMailer.LastMessage.HTML, "Confirm new email") {
|
||||
t.Fatalf("[email-change] Expected to sent a confirm new email email, got \n%v\n%v", app.TestMailer.LastMessage.Subject, app.TestMailer.LastMessage.HTML)
|
||||
}
|
||||
},
|
||||
ExpectedStatus: 204,
|
||||
ExpectedContent: []string{},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnMailerBeforeRecordChangeEmailSend": 1,
|
||||
"OnMailerAfterRecordChangeEmailSend": 1,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
scenario.Test(t)
|
||||
}
|
||||
}
|
||||
|
||||
-452
@@ -1,452 +0,0 @@
|
||||
package apis
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/forms"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/tokens"
|
||||
"github.com/pocketbase/pocketbase/tools/auth"
|
||||
"github.com/pocketbase/pocketbase/tools/rest"
|
||||
"github.com/pocketbase/pocketbase/tools/routine"
|
||||
"github.com/pocketbase/pocketbase/tools/search"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
// BindUserApi registers the user api endpoints and the corresponding handlers.
|
||||
func BindUserApi(app core.App, rg *echo.Group) {
|
||||
api := userApi{app: app}
|
||||
|
||||
subGroup := rg.Group("/users", ActivityLogger(app))
|
||||
subGroup.GET("/auth-methods", api.authMethods)
|
||||
subGroup.POST("/auth-via-oauth2", api.oauth2Auth, RequireGuestOnly())
|
||||
subGroup.POST("/auth-via-email", api.emailAuth, RequireGuestOnly())
|
||||
subGroup.POST("/request-password-reset", api.requestPasswordReset)
|
||||
subGroup.POST("/confirm-password-reset", api.confirmPasswordReset)
|
||||
subGroup.POST("/request-verification", api.requestVerification)
|
||||
subGroup.POST("/confirm-verification", api.confirmVerification)
|
||||
subGroup.POST("/request-email-change", api.requestEmailChange, RequireUserAuth())
|
||||
subGroup.POST("/confirm-email-change", api.confirmEmailChange)
|
||||
subGroup.POST("/refresh", api.refresh, RequireUserAuth())
|
||||
// crud
|
||||
subGroup.GET("", api.list, RequireAdminAuth())
|
||||
subGroup.POST("", api.create)
|
||||
subGroup.GET("/:id", api.view, RequireAdminOrOwnerAuth("id"))
|
||||
subGroup.PATCH("/:id", api.update, RequireAdminAuth())
|
||||
subGroup.DELETE("/:id", api.delete, RequireAdminOrOwnerAuth("id"))
|
||||
}
|
||||
|
||||
type userApi struct {
|
||||
app core.App
|
||||
}
|
||||
|
||||
func (api *userApi) authResponse(c echo.Context, user *models.User, meta any) error {
|
||||
token, tokenErr := tokens.NewUserAuthToken(api.app, user)
|
||||
if tokenErr != nil {
|
||||
return rest.NewBadRequestError("Failed to create auth token.", tokenErr)
|
||||
}
|
||||
|
||||
event := &core.UserAuthEvent{
|
||||
HttpContext: c,
|
||||
User: user,
|
||||
Token: token,
|
||||
Meta: meta,
|
||||
}
|
||||
|
||||
return api.app.OnUserAuthRequest().Trigger(event, func(e *core.UserAuthEvent) error {
|
||||
result := map[string]any{
|
||||
"token": e.Token,
|
||||
"user": e.User,
|
||||
}
|
||||
|
||||
if e.Meta != nil {
|
||||
result["meta"] = e.Meta
|
||||
}
|
||||
|
||||
return e.HttpContext.JSON(http.StatusOK, result)
|
||||
})
|
||||
}
|
||||
|
||||
func (api *userApi) refresh(c echo.Context) error {
|
||||
user, _ := c.Get(ContextUserKey).(*models.User)
|
||||
if user == nil {
|
||||
return rest.NewNotFoundError("Missing auth user context.", nil)
|
||||
}
|
||||
|
||||
return api.authResponse(c, user, nil)
|
||||
}
|
||||
|
||||
type providerInfo struct {
|
||||
Name string `json:"name"`
|
||||
State string `json:"state"`
|
||||
CodeVerifier string `json:"codeVerifier"`
|
||||
CodeChallenge string `json:"codeChallenge"`
|
||||
CodeChallengeMethod string `json:"codeChallengeMethod"`
|
||||
AuthUrl string `json:"authUrl"`
|
||||
}
|
||||
|
||||
func (api *userApi) authMethods(c echo.Context) error {
|
||||
result := struct {
|
||||
EmailPassword bool `json:"emailPassword"`
|
||||
AuthProviders []providerInfo `json:"authProviders"`
|
||||
}{
|
||||
EmailPassword: true,
|
||||
AuthProviders: []providerInfo{},
|
||||
}
|
||||
|
||||
settings := api.app.Settings()
|
||||
|
||||
result.EmailPassword = settings.EmailAuth.Enabled
|
||||
|
||||
nameConfigMap := settings.NamedAuthProviderConfigs()
|
||||
|
||||
for name, config := range nameConfigMap {
|
||||
if !config.Enabled {
|
||||
continue
|
||||
}
|
||||
|
||||
provider, err := auth.NewProviderByName(name)
|
||||
if err != nil {
|
||||
if api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
|
||||
// skip provider
|
||||
continue
|
||||
}
|
||||
|
||||
if err := config.SetupProvider(provider); err != nil {
|
||||
if api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
|
||||
// skip provider
|
||||
continue
|
||||
}
|
||||
|
||||
state := security.RandomString(30)
|
||||
codeVerifier := security.RandomString(30)
|
||||
codeChallenge := security.S256Challenge(codeVerifier)
|
||||
codeChallengeMethod := "S256"
|
||||
result.AuthProviders = append(result.AuthProviders, providerInfo{
|
||||
Name: name,
|
||||
State: state,
|
||||
CodeVerifier: codeVerifier,
|
||||
CodeChallenge: codeChallenge,
|
||||
CodeChallengeMethod: codeChallengeMethod,
|
||||
AuthUrl: provider.BuildAuthUrl(
|
||||
state,
|
||||
oauth2.SetAuthURLParam("code_challenge", codeChallenge),
|
||||
oauth2.SetAuthURLParam("code_challenge_method", codeChallengeMethod),
|
||||
) + "&redirect_uri=", // empty redirect_uri so that users can append their url
|
||||
})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
func (api *userApi) oauth2Auth(c echo.Context) error {
|
||||
form := forms.NewUserOauth2Login(api.app)
|
||||
if readErr := c.Bind(form); readErr != nil {
|
||||
return rest.NewBadRequestError("An error occurred while reading the submitted data.", readErr)
|
||||
}
|
||||
|
||||
user, authData, submitErr := form.Submit()
|
||||
if submitErr != nil {
|
||||
return rest.NewBadRequestError("Failed to authenticate.", submitErr)
|
||||
}
|
||||
|
||||
return api.authResponse(c, user, authData)
|
||||
}
|
||||
|
||||
func (api *userApi) emailAuth(c echo.Context) error {
|
||||
if !api.app.Settings().EmailAuth.Enabled {
|
||||
return rest.NewBadRequestError("Email/Password authentication is not enabled.", nil)
|
||||
}
|
||||
|
||||
form := forms.NewUserEmailLogin(api.app)
|
||||
if readErr := c.Bind(form); readErr != nil {
|
||||
return rest.NewBadRequestError("An error occurred while reading the submitted data.", readErr)
|
||||
}
|
||||
|
||||
user, submitErr := form.Submit()
|
||||
if submitErr != nil {
|
||||
return rest.NewBadRequestError("Failed to authenticate.", submitErr)
|
||||
}
|
||||
|
||||
return api.authResponse(c, user, nil)
|
||||
}
|
||||
|
||||
func (api *userApi) requestPasswordReset(c echo.Context) error {
|
||||
form := forms.NewUserPasswordResetRequest(api.app)
|
||||
if err := c.Bind(form); err != nil {
|
||||
return rest.NewBadRequestError("An error occurred while reading the submitted data.", err)
|
||||
}
|
||||
|
||||
if err := form.Validate(); err != nil {
|
||||
return rest.NewBadRequestError("An error occurred while validating the form.", err)
|
||||
}
|
||||
|
||||
// run in background because we don't need to show
|
||||
// the result to the user (prevents users enumeration)
|
||||
routine.FireAndForget(func() {
|
||||
if err := form.Submit(); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
})
|
||||
|
||||
return c.NoContent(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (api *userApi) confirmPasswordReset(c echo.Context) error {
|
||||
form := forms.NewUserPasswordResetConfirm(api.app)
|
||||
if readErr := c.Bind(form); readErr != nil {
|
||||
return rest.NewBadRequestError("An error occurred while reading the submitted data.", readErr)
|
||||
}
|
||||
|
||||
user, submitErr := form.Submit()
|
||||
if submitErr != nil {
|
||||
return rest.NewBadRequestError("Failed to set new password.", submitErr)
|
||||
}
|
||||
|
||||
return api.authResponse(c, user, nil)
|
||||
}
|
||||
|
||||
func (api *userApi) requestEmailChange(c echo.Context) error {
|
||||
loggedUser, _ := c.Get(ContextUserKey).(*models.User)
|
||||
if loggedUser == nil {
|
||||
return rest.NewUnauthorizedError("The request requires valid authorized user.", nil)
|
||||
}
|
||||
|
||||
form := forms.NewUserEmailChangeRequest(api.app, loggedUser)
|
||||
if err := c.Bind(form); err != nil {
|
||||
return rest.NewBadRequestError("An error occurred while reading the submitted data.", err)
|
||||
}
|
||||
|
||||
if err := form.Submit(); err != nil {
|
||||
return rest.NewBadRequestError("Failed to request email change.", err)
|
||||
}
|
||||
|
||||
return c.NoContent(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (api *userApi) confirmEmailChange(c echo.Context) error {
|
||||
form := forms.NewUserEmailChangeConfirm(api.app)
|
||||
if readErr := c.Bind(form); readErr != nil {
|
||||
return rest.NewBadRequestError("An error occurred while reading the submitted data.", readErr)
|
||||
}
|
||||
|
||||
user, submitErr := form.Submit()
|
||||
if submitErr != nil {
|
||||
return rest.NewBadRequestError("Failed to confirm email change.", submitErr)
|
||||
}
|
||||
|
||||
return api.authResponse(c, user, nil)
|
||||
}
|
||||
|
||||
func (api *userApi) requestVerification(c echo.Context) error {
|
||||
form := forms.NewUserVerificationRequest(api.app)
|
||||
if err := c.Bind(form); err != nil {
|
||||
return rest.NewBadRequestError("An error occurred while reading the submitted data.", err)
|
||||
}
|
||||
|
||||
if err := form.Validate(); err != nil {
|
||||
return rest.NewBadRequestError("An error occurred while validating the form.", err)
|
||||
}
|
||||
|
||||
// run in background because we don't need to show
|
||||
// the result to the user (prevents users enumeration)
|
||||
routine.FireAndForget(func() {
|
||||
if err := form.Submit(); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
})
|
||||
|
||||
return c.NoContent(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (api *userApi) confirmVerification(c echo.Context) error {
|
||||
form := forms.NewUserVerificationConfirm(api.app)
|
||||
if readErr := c.Bind(form); readErr != nil {
|
||||
return rest.NewBadRequestError("An error occurred while reading the submitted data.", readErr)
|
||||
}
|
||||
|
||||
user, submitErr := form.Submit()
|
||||
if submitErr != nil {
|
||||
return rest.NewBadRequestError("An error occurred while submitting the form.", submitErr)
|
||||
}
|
||||
|
||||
return api.authResponse(c, user, nil)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// CRUD
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
func (api *userApi) list(c echo.Context) error {
|
||||
fieldResolver := search.NewSimpleFieldResolver(
|
||||
"id", "created", "updated", "email", "verified",
|
||||
)
|
||||
|
||||
users := []*models.User{}
|
||||
|
||||
result, searchErr := search.NewProvider(fieldResolver).
|
||||
Query(api.app.Dao().UserQuery()).
|
||||
ParseAndExec(c.QueryString(), &users)
|
||||
if searchErr != nil {
|
||||
return rest.NewBadRequestError("", searchErr)
|
||||
}
|
||||
|
||||
// eager load user profiles (if any)
|
||||
if err := api.app.Dao().LoadProfiles(users); err != nil {
|
||||
return rest.NewBadRequestError("", err)
|
||||
}
|
||||
|
||||
event := &core.UsersListEvent{
|
||||
HttpContext: c,
|
||||
Users: users,
|
||||
Result: result,
|
||||
}
|
||||
|
||||
return api.app.OnUsersListRequest().Trigger(event, func(e *core.UsersListEvent) error {
|
||||
return e.HttpContext.JSON(http.StatusOK, e.Result)
|
||||
})
|
||||
}
|
||||
|
||||
func (api *userApi) view(c echo.Context) error {
|
||||
id := c.PathParam("id")
|
||||
if id == "" {
|
||||
return rest.NewNotFoundError("", nil)
|
||||
}
|
||||
|
||||
user, err := api.app.Dao().FindUserById(id)
|
||||
if err != nil || user == nil {
|
||||
return rest.NewNotFoundError("", err)
|
||||
}
|
||||
|
||||
event := &core.UserViewEvent{
|
||||
HttpContext: c,
|
||||
User: user,
|
||||
}
|
||||
|
||||
return api.app.OnUserViewRequest().Trigger(event, func(e *core.UserViewEvent) error {
|
||||
return e.HttpContext.JSON(http.StatusOK, e.User)
|
||||
})
|
||||
}
|
||||
|
||||
func (api *userApi) create(c echo.Context) error {
|
||||
if !api.app.Settings().EmailAuth.Enabled {
|
||||
return rest.NewBadRequestError("Email/Password authentication is not enabled.", nil)
|
||||
}
|
||||
|
||||
user := &models.User{}
|
||||
form := forms.NewUserUpsert(api.app, user)
|
||||
|
||||
// load request
|
||||
if err := c.Bind(form); err != nil {
|
||||
return rest.NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
|
||||
}
|
||||
|
||||
event := &core.UserCreateEvent{
|
||||
HttpContext: c,
|
||||
User: user,
|
||||
}
|
||||
|
||||
// create the user
|
||||
submitErr := form.Submit(func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
|
||||
return func() error {
|
||||
return api.app.OnUserBeforeCreateRequest().Trigger(event, func(e *core.UserCreateEvent) error {
|
||||
if err := next(); err != nil {
|
||||
return rest.NewBadRequestError("Failed to create user.", err)
|
||||
}
|
||||
|
||||
return e.HttpContext.JSON(http.StatusOK, e.User)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if submitErr == nil {
|
||||
api.app.OnUserAfterCreateRequest().Trigger(event)
|
||||
}
|
||||
|
||||
return submitErr
|
||||
}
|
||||
|
||||
func (api *userApi) update(c echo.Context) error {
|
||||
id := c.PathParam("id")
|
||||
if id == "" {
|
||||
return rest.NewNotFoundError("", nil)
|
||||
}
|
||||
|
||||
user, err := api.app.Dao().FindUserById(id)
|
||||
if err != nil || user == nil {
|
||||
return rest.NewNotFoundError("", err)
|
||||
}
|
||||
|
||||
form := forms.NewUserUpsert(api.app, user)
|
||||
|
||||
// load request
|
||||
if err := c.Bind(form); err != nil {
|
||||
return rest.NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
|
||||
}
|
||||
|
||||
event := &core.UserUpdateEvent{
|
||||
HttpContext: c,
|
||||
User: user,
|
||||
}
|
||||
|
||||
// update the user
|
||||
submitErr := form.Submit(func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
|
||||
return func() error {
|
||||
return api.app.OnUserBeforeUpdateRequest().Trigger(event, func(e *core.UserUpdateEvent) error {
|
||||
if err := next(); err != nil {
|
||||
return rest.NewBadRequestError("Failed to update user.", err)
|
||||
}
|
||||
|
||||
return e.HttpContext.JSON(http.StatusOK, e.User)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if submitErr == nil {
|
||||
api.app.OnUserAfterUpdateRequest().Trigger(event)
|
||||
}
|
||||
|
||||
return submitErr
|
||||
}
|
||||
|
||||
func (api *userApi) delete(c echo.Context) error {
|
||||
id := c.PathParam("id")
|
||||
if id == "" {
|
||||
return rest.NewNotFoundError("", nil)
|
||||
}
|
||||
|
||||
user, err := api.app.Dao().FindUserById(id)
|
||||
if err != nil || user == nil {
|
||||
return rest.NewNotFoundError("", err)
|
||||
}
|
||||
|
||||
event := &core.UserDeleteEvent{
|
||||
HttpContext: c,
|
||||
User: user,
|
||||
}
|
||||
|
||||
handlerErr := api.app.OnUserBeforeDeleteRequest().Trigger(event, func(e *core.UserDeleteEvent) error {
|
||||
// delete the user model
|
||||
if err := api.app.Dao().DeleteUser(e.User); err != nil {
|
||||
return rest.NewBadRequestError("Failed to delete user. Make sure that the user is not part of a required relation reference.", err)
|
||||
}
|
||||
|
||||
return e.HttpContext.NoContent(http.StatusNoContent)
|
||||
})
|
||||
|
||||
if handlerErr == nil {
|
||||
api.app.OnUserAfterDeleteRequest().Trigger(event)
|
||||
}
|
||||
|
||||
return handlerErr
|
||||
}
|
||||
@@ -1,928 +0,0 @@
|
||||
package apis_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
)
|
||||
|
||||
func TestUsersAuthMethods(t *testing.T) {
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/users/auth-methods",
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"emailPassword":true`,
|
||||
`"authProviders":[{`,
|
||||
`"authProviders":[{`,
|
||||
`"name":"gitlab"`,
|
||||
`"state":`,
|
||||
`"codeVerifier":`,
|
||||
`"codeChallenge":`,
|
||||
`"codeChallengeMethod":`,
|
||||
`"authUrl":`,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
scenario.Test(t)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserEmailAuth(t *testing.T) {
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "authorized as user",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/users/auth-via-email",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "authorized as admin",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/users/auth-via-email",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "invalid body format",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/users/auth-via-email",
|
||||
Body: strings.NewReader(`{"email`),
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "invalid data",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/users/auth-via-email",
|
||||
Body: strings.NewReader(`{"email":"","password":""}`),
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{
|
||||
`"data":{`,
|
||||
`"email":{`,
|
||||
`"password":{`,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "disabled email/pass auth with valid data",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/users/auth-via-email",
|
||||
Body: strings.NewReader(`{"email":"test@example.com","password":"123456"}`),
|
||||
BeforeFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
app.Settings().EmailAuth.Enabled = false
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "valid data",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/users/auth-via-email",
|
||||
Body: strings.NewReader(`{"email":"test2@example.com","password":"123456"}`),
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"token"`,
|
||||
`"user"`,
|
||||
`"id":"7bc84d27-6ba2-b42a-383f-4197cc3d3d0c"`,
|
||||
`"email":"test2@example.com"`,
|
||||
`"verified":false`, // unverified user should be able to authenticate
|
||||
},
|
||||
ExpectedEvents: map[string]int{"OnUserAuthRequest": 1},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
scenario.Test(t)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserRequestPasswordReset(t *testing.T) {
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "empty data",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/users/request-password-reset",
|
||||
Body: strings.NewReader(``),
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{"email":{"code":"validation_required","message":"Cannot be blank."}}`},
|
||||
},
|
||||
{
|
||||
Name: "invalid data",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/users/request-password-reset",
|
||||
Body: strings.NewReader(`{"email`),
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "missing user",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/users/request-password-reset",
|
||||
Body: strings.NewReader(`{"email":"missing@example.com"}`),
|
||||
Delay: 100 * time.Millisecond,
|
||||
ExpectedStatus: 204,
|
||||
},
|
||||
{
|
||||
Name: "existing user",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/users/request-password-reset",
|
||||
Body: strings.NewReader(`{"email":"test@example.com"}`),
|
||||
Delay: 100 * time.Millisecond,
|
||||
ExpectedStatus: 204,
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnModelBeforeUpdate": 1,
|
||||
"OnModelAfterUpdate": 1,
|
||||
"OnMailerBeforeUserResetPasswordSend": 1,
|
||||
"OnMailerAfterUserResetPasswordSend": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "existing user (after already sent)",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/users/request-password-reset",
|
||||
Body: strings.NewReader(`{"email":"test@example.com"}`),
|
||||
Delay: 100 * time.Millisecond,
|
||||
ExpectedStatus: 204,
|
||||
BeforeFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
// simulate recent password request
|
||||
user, err := app.Dao().FindUserByEmail("test@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
user.LastResetSentAt = types.NowDateTime()
|
||||
dao := daos.New(app.Dao().DB()) // new dao to ignore hooks
|
||||
if err := dao.Save(user); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
scenario.Test(t)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserConfirmPasswordReset(t *testing.T) {
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "empty data",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/users/confirm-password-reset",
|
||||
Body: strings.NewReader(``),
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{"password":{"code":"validation_required","message":"Cannot be blank."},"passwordConfirm":{"code":"validation_required","message":"Cannot be blank."},"token":{"code":"validation_required","message":"Cannot be blank."}}`},
|
||||
},
|
||||
{
|
||||
Name: "invalid data format",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/users/confirm-password-reset",
|
||||
Body: strings.NewReader(`{"password`),
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "expired token",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/users/confirm-password-reset",
|
||||
Body: strings.NewReader(`{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0eXBlIjoidXNlciIsImlkIjoiNGQwMTk3Y2MtMmI0YS0zZjgzLWEyNmItZDc3YmM4NDIzZDNjIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwiZXhwIjoxNjQxMDMxMjAwfQ.t2lVe0ny9XruQsSFQdXqBi0I85i6vIUAQjFXZY5HPxc","password":"123456789","passwordConfirm":"123456789"}`),
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{
|
||||
`"data":{`,
|
||||
`"token":{`,
|
||||
`"code":"validation_invalid_token"`,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "valid token and data",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/users/confirm-password-reset",
|
||||
Body: strings.NewReader(`{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0eXBlIjoidXNlciIsImlkIjoiNGQwMTk3Y2MtMmI0YS0zZjgzLWEyNmItZDc3YmM4NDIzZDNjIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwiZXhwIjoxODYxOTU2MDAwfQ.V1gEbY4caEIF6IhQAJ8KZD4RvOGvTCFuYg1fTRSvhe0","password":"123456789","passwordConfirm":"123456789"}`),
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"token":`,
|
||||
`"user":`,
|
||||
`"id":"4d0197cc-2b4a-3f83-a26b-d77bc8423d3c"`,
|
||||
`"email":"test@example.com"`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{"OnUserAuthRequest": 1, "OnModelAfterUpdate": 1, "OnModelBeforeUpdate": 1},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
scenario.Test(t)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserRequestVerification(t *testing.T) {
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "empty data",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/users/request-verification",
|
||||
Body: strings.NewReader(``),
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{"email":{"code":"validation_required","message":"Cannot be blank."}}`},
|
||||
},
|
||||
{
|
||||
Name: "invalid data",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/users/request-verification",
|
||||
Body: strings.NewReader(`{"email`),
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "missing user",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/users/request-verification",
|
||||
Body: strings.NewReader(`{"email":"missing@example.com"}`),
|
||||
Delay: 100 * time.Millisecond,
|
||||
ExpectedStatus: 204,
|
||||
},
|
||||
{
|
||||
Name: "existing already verified user",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/users/request-verification",
|
||||
Body: strings.NewReader(`{"email":"test@example.com"}`),
|
||||
Delay: 100 * time.Millisecond,
|
||||
ExpectedStatus: 204,
|
||||
},
|
||||
{
|
||||
Name: "existing unverified user",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/users/request-verification",
|
||||
Body: strings.NewReader(`{"email":"test2@example.com"}`),
|
||||
Delay: 100 * time.Millisecond,
|
||||
ExpectedStatus: 204,
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnModelBeforeUpdate": 1,
|
||||
"OnModelAfterUpdate": 1,
|
||||
"OnMailerBeforeUserVerificationSend": 1,
|
||||
"OnMailerAfterUserVerificationSend": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "existing unverified user (after already sent)",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/users/request-verification",
|
||||
Body: strings.NewReader(`{"email":"test2@example.com"}`),
|
||||
Delay: 100 * time.Millisecond,
|
||||
ExpectedStatus: 204,
|
||||
BeforeFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
// simulate recent verification sent
|
||||
user, err := app.Dao().FindUserByEmail("test2@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
user.LastVerificationSentAt = types.NowDateTime()
|
||||
dao := daos.New(app.Dao().DB()) // new dao to ignore hooks
|
||||
if err := dao.Save(user); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
scenario.Test(t)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserConfirmVerification(t *testing.T) {
|
||||
scenarios := []tests.ApiScenario{
|
||||
// empty data
|
||||
{
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/users/confirm-verification",
|
||||
Body: strings.NewReader(``),
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{
|
||||
`"data":`,
|
||||
`"token":{"code":"validation_required"`,
|
||||
},
|
||||
},
|
||||
// invalid data
|
||||
{
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/users/confirm-verification",
|
||||
Body: strings.NewReader(`{"token`),
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
// expired token
|
||||
{
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/users/confirm-verification",
|
||||
Body: strings.NewReader(`{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0eXBlIjoidXNlciIsImlkIjoiN2JjODRkMjctNmJhMi1iNDJhLTM4M2YtNDE5N2NjM2QzZDBjIiwiZW1haWwiOiJ0ZXN0MkBleGFtcGxlLmNvbSIsImV4cCI6MTY0MTAzMTIwMH0.YCqyREksfqn7cWu-innNNTbWQCr9DgYr7dduM2wxrtQ"}`),
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{
|
||||
`"data":{`,
|
||||
`"token":{`,
|
||||
`"code":"validation_invalid_token"`,
|
||||
},
|
||||
},
|
||||
// valid token
|
||||
{
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/users/confirm-verification",
|
||||
Body: strings.NewReader(`{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0eXBlIjoidXNlciIsImlkIjoiN2JjODRkMjctNmJhMi1iNDJhLTM4M2YtNDE5N2NjM2QzZDBjIiwiZW1haWwiOiJ0ZXN0MkBleGFtcGxlLmNvbSIsImV4cCI6MTg2MTk1NjAwMH0.OsxRKuZrNTnwyVjvCwB4jY8TbT-NPZ-UFCpRhCvuv2U"}`),
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"token":`,
|
||||
`"user":`,
|
||||
`"id":"7bc84d27-6ba2-b42a-383f-4197cc3d3d0c"`,
|
||||
`"email":"test2@example.com"`,
|
||||
`"verified":true`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnUserAuthRequest": 1,
|
||||
"OnModelAfterUpdate": 1,
|
||||
"OnModelBeforeUpdate": 1,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
scenario.Test(t)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserRequestEmailChange(t *testing.T) {
|
||||
scenarios := []tests.ApiScenario{
|
||||
// unauthorized
|
||||
{
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/users/request-email-change",
|
||||
Body: strings.NewReader(`{"newEmail":"change@example.com"}`),
|
||||
ExpectedStatus: 401,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
// authorized as admin
|
||||
{
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/users/request-email-change",
|
||||
Body: strings.NewReader(`{"newEmail":"change@example.com"}`),
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
|
||||
},
|
||||
ExpectedStatus: 401,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
// invalid data
|
||||
{
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/users/request-email-change",
|
||||
Body: strings.NewReader(`{"newEmail`),
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
// empty data
|
||||
{
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/users/request-email-change",
|
||||
Body: strings.NewReader(``),
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{
|
||||
`"data":`,
|
||||
`"newEmail":{"code":"validation_required"`,
|
||||
},
|
||||
},
|
||||
// valid data (existing email)
|
||||
{
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/users/request-email-change",
|
||||
Body: strings.NewReader(`{"newEmail":"test2@example.com"}`),
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{
|
||||
`"data":`,
|
||||
`"newEmail":{"code":"validation_user_email_exists"`,
|
||||
},
|
||||
},
|
||||
// valid data (new email)
|
||||
{
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/users/request-email-change",
|
||||
Body: strings.NewReader(`{"newEmail":"change@example.com"}`),
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
|
||||
},
|
||||
ExpectedStatus: 204,
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnMailerBeforeUserChangeEmailSend": 1,
|
||||
"OnMailerAfterUserChangeEmailSend": 1,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
scenario.Test(t)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserConfirmEmailChange(t *testing.T) {
|
||||
scenarios := []tests.ApiScenario{
|
||||
// empty data
|
||||
{
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/users/confirm-email-change",
|
||||
Body: strings.NewReader(``),
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{
|
||||
`"data":`,
|
||||
`"token":{"code":"validation_required"`,
|
||||
`"password":{"code":"validation_required"`,
|
||||
},
|
||||
},
|
||||
// invalid data
|
||||
{
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/users/confirm-email-change",
|
||||
Body: strings.NewReader(`{"token`),
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
// expired token and correct password
|
||||
{
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/users/confirm-email-change",
|
||||
Body: strings.NewReader(`{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjdiYzg0ZDI3LTZiYTItYjQyYS0zODNmLTQxOTdjYzNkM2QwYyIsInR5cGUiOiJ1c2VyIiwiZW1haWwiOiJ0ZXN0MkBleGFtcGxlLmNvbSIsIm5ld0VtYWlsIjoiY2hhbmdlQGV4YW1wbGUuY29tIiwiZXhwIjoxNjQwOTkxNjAwfQ.DOqNtSDcXbWix8OsK13X-tjfWi6jZNlAzIZiwG_YDOs","password":"123456"}`),
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{
|
||||
`"data":{`,
|
||||
`"token":{`,
|
||||
`"code":"validation_invalid_token"`,
|
||||
},
|
||||
},
|
||||
// valid token and incorrect password
|
||||
{
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/users/confirm-email-change",
|
||||
Body: strings.NewReader(`{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjdiYzg0ZDI3LTZiYTItYjQyYS0zODNmLTQxOTdjYzNkM2QwYyIsInR5cGUiOiJ1c2VyIiwiZW1haWwiOiJ0ZXN0MkBleGFtcGxlLmNvbSIsIm5ld0VtYWlsIjoiY2hhbmdlQGV4YW1wbGUuY29tIiwiZXhwIjoxODkzNDUyNDAwfQ.aWMQJ_c49yFbzHO5TNhlkbKRokQ_isc2RbLGuSJx44c","password":"654321"}`),
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{
|
||||
`"data":{`,
|
||||
`"password":{`,
|
||||
`"code":"validation_invalid_password"`,
|
||||
},
|
||||
},
|
||||
// valid token and correct password
|
||||
{
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/users/confirm-email-change",
|
||||
Body: strings.NewReader(`{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjdiYzg0ZDI3LTZiYTItYjQyYS0zODNmLTQxOTdjYzNkM2QwYyIsInR5cGUiOiJ1c2VyIiwiZW1haWwiOiJ0ZXN0MkBleGFtcGxlLmNvbSIsIm5ld0VtYWlsIjoiY2hhbmdlQGV4YW1wbGUuY29tIiwiZXhwIjoxODkzNDUyNDAwfQ.aWMQJ_c49yFbzHO5TNhlkbKRokQ_isc2RbLGuSJx44c","password":"123456"}`),
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"token":`,
|
||||
`"user":`,
|
||||
`"id":"7bc84d27-6ba2-b42a-383f-4197cc3d3d0c"`,
|
||||
`"email":"change@example.com"`,
|
||||
`"verified":true`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{"OnUserAuthRequest": 1, "OnModelAfterUpdate": 1, "OnModelBeforeUpdate": 1},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
scenario.Test(t)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserRefresh(t *testing.T) {
|
||||
scenarios := []tests.ApiScenario{
|
||||
// unauthorized
|
||||
{
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/users/refresh",
|
||||
ExpectedStatus: 401,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
// authorized as admin
|
||||
{
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/users/refresh",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
|
||||
},
|
||||
ExpectedStatus: 401,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
// authorized as user
|
||||
{
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/users/refresh",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"token":`,
|
||||
`"user":`,
|
||||
`"id":"4d0197cc-2b4a-3f83-a26b-d77bc8423d3c"`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{"OnUserAuthRequest": 1},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
scenario.Test(t)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUsersList(t *testing.T) {
|
||||
scenarios := []tests.ApiScenario{
|
||||
// unauthorized
|
||||
{
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/users",
|
||||
ExpectedStatus: 401,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
// authorized as user
|
||||
{
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/users",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
|
||||
},
|
||||
ExpectedStatus: 401,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
// authorized as admin
|
||||
{
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/users",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"page":1`,
|
||||
`"perPage":30`,
|
||||
`"totalItems":3`,
|
||||
`"items":[{`,
|
||||
`"id":"4d0197cc-2b4a-3f83-a26b-d77bc8423d3c"`,
|
||||
`"id":"7bc84d27-6ba2-b42a-383f-4197cc3d3d0c"`,
|
||||
`"id":"97cc3d3d-6ba2-383f-b42a-7bc84d27410c"`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{"OnUsersListRequest": 1},
|
||||
},
|
||||
// authorized as admin + paging and sorting
|
||||
{
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/users?page=2&perPage=2&sort=-created",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"page":2`,
|
||||
`"perPage":2`,
|
||||
`"totalItems":3`,
|
||||
`"items":[{`,
|
||||
`"id":"4d0197cc-2b4a-3f83-a26b-d77bc8423d3c"`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{"OnUsersListRequest": 1},
|
||||
},
|
||||
// authorized as admin + invalid filter
|
||||
{
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/users?filter=invalidfield~'test2'",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
// authorized as admin + valid filter
|
||||
{
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/users?filter=verified=true",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"page":1`,
|
||||
`"perPage":30`,
|
||||
`"totalItems":2`,
|
||||
`"items":[{`,
|
||||
`"id":"4d0197cc-2b4a-3f83-a26b-d77bc8423d3c"`,
|
||||
`"id":"97cc3d3d-6ba2-383f-b42a-7bc84d27410c"`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{"OnUsersListRequest": 1},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
scenario.Test(t)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserView(t *testing.T) {
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "unauthorized",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/users/4d0197cc-2b4a-3f83-a26b-d77bc8423d3c",
|
||||
ExpectedStatus: 401,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "authorized as admin + nonexisting user id",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/users/00000000-0000-0000-0000-d77bc8423d3c",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
|
||||
},
|
||||
ExpectedStatus: 404,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "authorized as admin + existing user id",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/users/4d0197cc-2b4a-3f83-a26b-d77bc8423d3c",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"id":"4d0197cc-2b4a-3f83-a26b-d77bc8423d3c"`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{"OnUserViewRequest": 1},
|
||||
},
|
||||
{
|
||||
Name: "authorized as user - trying to view another user",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/users/7bc84d27-6ba2-b42a-383f-4197cc3d3d0c",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
|
||||
},
|
||||
ExpectedStatus: 403,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "authorized as user - owner",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/users/4d0197cc-2b4a-3f83-a26b-d77bc8423d3c",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"id":"4d0197cc-2b4a-3f83-a26b-d77bc8423d3c"`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{"OnUserViewRequest": 1},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
scenario.Test(t)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserDelete(t *testing.T) {
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "unauthorized",
|
||||
Method: http.MethodDelete,
|
||||
Url: "/api/users/4d0197cc-2b4a-3f83-a26b-d77bc8423d3c",
|
||||
ExpectedStatus: 401,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "authorized as admin + nonexisting user id",
|
||||
Method: http.MethodDelete,
|
||||
Url: "/api/users/00000000-0000-0000-0000-d77bc8423d3c",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
|
||||
},
|
||||
ExpectedStatus: 404,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "authorized as admin + existing user id",
|
||||
Method: http.MethodDelete,
|
||||
Url: "/api/users/4d0197cc-2b4a-3f83-a26b-d77bc8423d3c",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
|
||||
},
|
||||
ExpectedStatus: 204,
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnUserBeforeDeleteRequest": 1,
|
||||
"OnUserAfterDeleteRequest": 1,
|
||||
"OnModelBeforeDelete": 2, // cascade delete to related Record model
|
||||
"OnModelAfterDelete": 2, // cascade delete to related Record model
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "authorized as user - trying to delete another user",
|
||||
Method: http.MethodDelete,
|
||||
Url: "/api/users/7bc84d27-6ba2-b42a-383f-4197cc3d3d0c",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
|
||||
},
|
||||
ExpectedStatus: 403,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "authorized as user - owner",
|
||||
Method: http.MethodDelete,
|
||||
Url: "/api/users/4d0197cc-2b4a-3f83-a26b-d77bc8423d3c",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
|
||||
},
|
||||
ExpectedStatus: 204,
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnUserBeforeDeleteRequest": 1,
|
||||
"OnUserAfterDeleteRequest": 1,
|
||||
"OnModelBeforeDelete": 2, // cascade delete to related Record model
|
||||
"OnModelAfterDelete": 2, // cascade delete to related Record model
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
scenario.Test(t)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserCreate(t *testing.T) {
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "empty data",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/users",
|
||||
Body: strings.NewReader(``),
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{
|
||||
`"data":{`,
|
||||
`"email":{"code":"validation_required"`,
|
||||
`"password":{"code":"validation_required"`,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "invalid data",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/users",
|
||||
Body: strings.NewReader(`{"email":"test@example.com","password":"1234","passwordConfirm":"4321"}`),
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{
|
||||
`"data":{`,
|
||||
`"email":{"code":"validation_user_email_exists"`,
|
||||
`"password":{"code":"validation_length_out_of_range"`,
|
||||
`"passwordConfirm":{"code":"validation_values_mismatch"`,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "valid data but with disabled email/pass auth",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/users",
|
||||
Body: strings.NewReader(`{"email":"newuser@example.com","password":"123456789","passwordConfirm":"123456789"}`),
|
||||
BeforeFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
app.Settings().EmailAuth.Enabled = false
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "valid data",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/users",
|
||||
Body: strings.NewReader(`{"email":"newuser@example.com","password":"123456789","passwordConfirm":"123456789"}`),
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"id":`,
|
||||
`"email":"newuser@example.com"`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnUserBeforeCreateRequest": 1,
|
||||
"OnUserAfterCreateRequest": 1,
|
||||
"OnModelBeforeCreate": 2, // +1 for the created profile record
|
||||
"OnModelAfterCreate": 2, // +1 for the created profile record
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
scenario.Test(t)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserUpdate(t *testing.T) {
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "unauthorized",
|
||||
Method: http.MethodPatch,
|
||||
Url: "/api/users/4d0197cc-2b4a-3f83-a26b-d77bc8423d3c",
|
||||
Body: strings.NewReader(`{"email":"new@example.com"}`),
|
||||
ExpectedStatus: 401,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "authorized as user (owner)",
|
||||
Method: http.MethodPatch,
|
||||
Url: "/api/users/4d0197cc-2b4a-3f83-a26b-d77bc8423d3c",
|
||||
Body: strings.NewReader(`{"email":"new@example.com"}`),
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
|
||||
},
|
||||
ExpectedStatus: 401,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "authorized as admin - invalid/missing user id",
|
||||
Method: http.MethodPatch,
|
||||
Url: "/api/users/invalid",
|
||||
Body: strings.NewReader(``),
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
|
||||
},
|
||||
ExpectedStatus: 404,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "authorized as admin - empty data",
|
||||
Method: http.MethodPatch,
|
||||
Url: "/api/users/4d0197cc-2b4a-3f83-a26b-d77bc8423d3c",
|
||||
Body: strings.NewReader(``),
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"id":"4d0197cc-2b4a-3f83-a26b-d77bc8423d3c"`,
|
||||
`"email":"test@example.com"`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnUserBeforeUpdateRequest": 1,
|
||||
"OnUserAfterUpdateRequest": 1,
|
||||
"OnModelBeforeUpdate": 1,
|
||||
"OnModelAfterUpdate": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "authorized as admin - invalid data",
|
||||
Method: http.MethodPatch,
|
||||
Url: "/api/users/4d0197cc-2b4a-3f83-a26b-d77bc8423d3c",
|
||||
Body: strings.NewReader(`{"email":"test2@example.com"}`),
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{
|
||||
`"data":{`,
|
||||
`"email":{"code":"validation_user_email_exists"`,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "authorized as admin - valid data",
|
||||
Method: http.MethodPatch,
|
||||
Url: "/api/users/4d0197cc-2b4a-3f83-a26b-d77bc8423d3c",
|
||||
Body: strings.NewReader(`{"email":"new@example.com"}`),
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"id":"4d0197cc-2b4a-3f83-a26b-d77bc8423d3c"`,
|
||||
`"email":"new@example.com"`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnUserBeforeUpdateRequest": 1,
|
||||
"OnUserAfterUpdateRequest": 1,
|
||||
"OnModelBeforeUpdate": 1,
|
||||
"OnModelAfterUpdate": 1,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
scenario.Test(t)
|
||||
}
|
||||
}
|
||||
-241
@@ -1,241 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path"
|
||||
"time"
|
||||
|
||||
"github.com/AlecAivazis/survey/v2"
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
"github.com/pocketbase/pocketbase/migrations"
|
||||
"github.com/pocketbase/pocketbase/migrations/logs"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/tools/inflector"
|
||||
"github.com/pocketbase/pocketbase/tools/migrate"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// NewMigrateCommand creates and returns new command for handling DB migrations.
|
||||
func NewMigrateCommand(app core.App) *cobra.Command {
|
||||
desc := `
|
||||
Supported arguments are:
|
||||
- up - runs all available migrations.
|
||||
- down [number] - reverts the last [number] applied migrations.
|
||||
- create name [folder] - creates new migration template file.
|
||||
- collections [folder] - (Experimental) creates new migration file with the most recent local collections configuration.
|
||||
`
|
||||
var databaseFlag string
|
||||
|
||||
command := &cobra.Command{
|
||||
Use: "migrate",
|
||||
Short: "Executes DB migration scripts",
|
||||
ValidArgs: []string{"up", "down", "create", "collections"},
|
||||
Long: desc,
|
||||
Run: func(command *cobra.Command, args []string) {
|
||||
cmd := ""
|
||||
if len(args) > 0 {
|
||||
cmd = args[0]
|
||||
}
|
||||
|
||||
// additional commands
|
||||
// ---
|
||||
if cmd == "create" {
|
||||
if err := migrateCreateHandler(defaultMigrateCreateTemplate, args[1:]); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if cmd == "collections" {
|
||||
if err := migrateCollectionsHandler(app, args[1:]); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
// ---
|
||||
|
||||
// normalize
|
||||
if databaseFlag != "logs" {
|
||||
databaseFlag = "db"
|
||||
}
|
||||
|
||||
connections := migrationsConnectionsMap(app)
|
||||
|
||||
runner, err := migrate.NewRunner(
|
||||
connections[databaseFlag].DB,
|
||||
connections[databaseFlag].MigrationsList,
|
||||
)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
if err := runner.Run(args...); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
command.PersistentFlags().StringVar(
|
||||
&databaseFlag,
|
||||
"database",
|
||||
"db",
|
||||
"specify the database connection to use (db or logs)",
|
||||
)
|
||||
|
||||
return command
|
||||
}
|
||||
|
||||
type migrationsConnection struct {
|
||||
DB *dbx.DB
|
||||
MigrationsList migrate.MigrationsList
|
||||
}
|
||||
|
||||
func migrationsConnectionsMap(app core.App) map[string]migrationsConnection {
|
||||
return map[string]migrationsConnection{
|
||||
"db": {
|
||||
DB: app.DB(),
|
||||
MigrationsList: migrations.AppMigrations,
|
||||
},
|
||||
"logs": {
|
||||
DB: app.LogsDB(),
|
||||
MigrationsList: logs.LogsMigrations,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// migrate create
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
const defaultMigrateCreateTemplate = `package migrations
|
||||
|
||||
import (
|
||||
"github.com/pocketbase/dbx"
|
||||
m "github.com/pocketbase/pocketbase/migrations"
|
||||
)
|
||||
|
||||
func init() {
|
||||
m.Register(func(db dbx.Builder) error {
|
||||
// add up queries...
|
||||
|
||||
return nil
|
||||
}, func(db dbx.Builder) error {
|
||||
// add down queries...
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
`
|
||||
|
||||
func migrateCreateHandler(template string, args []string) error {
|
||||
if len(args) < 1 {
|
||||
return fmt.Errorf("Missing migration file name")
|
||||
}
|
||||
|
||||
name := args[0]
|
||||
|
||||
var dir string
|
||||
if len(args) == 2 {
|
||||
dir = args[1]
|
||||
}
|
||||
if dir == "" {
|
||||
// If not specified, auto point to the default migrations folder.
|
||||
//
|
||||
// NB!
|
||||
// Since the create command makes sense only during development,
|
||||
// it is expected the user to be in the app working directory
|
||||
// and to be using `go run`
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dir = path.Join(wd, "migrations")
|
||||
}
|
||||
|
||||
resultFilePath := path.Join(
|
||||
dir,
|
||||
fmt.Sprintf("%d_%s.go", time.Now().Unix(), inflector.Snakecase(name)),
|
||||
)
|
||||
|
||||
confirm := false
|
||||
prompt := &survey.Confirm{
|
||||
Message: fmt.Sprintf("Do you really want to create migration %q?", resultFilePath),
|
||||
}
|
||||
survey.AskOne(prompt, &confirm)
|
||||
if !confirm {
|
||||
fmt.Println("The command has been cancelled")
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensure that migrations dir exist
|
||||
if err := os.MkdirAll(dir, os.ModePerm); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := os.WriteFile(resultFilePath, []byte(template), 0644); err != nil {
|
||||
return fmt.Errorf("Failed to save migration file %q\n", resultFilePath)
|
||||
}
|
||||
|
||||
fmt.Printf("Successfully created file %q\n", resultFilePath)
|
||||
return nil
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// migrate collections
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
const collectionsMigrateCreateTemplate = `package migrations
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
m "github.com/pocketbase/pocketbase/migrations"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
)
|
||||
|
||||
// Auto generated migration with the most recent collections configuration.
|
||||
func init() {
|
||||
m.Register(func(db dbx.Builder) error {
|
||||
jsonData := ` + "`" + `%s` + "`" + `
|
||||
|
||||
collections := []*models.Collection{}
|
||||
if err := json.Unmarshal([]byte(jsonData), &collections); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return daos.New(db).ImportCollections(collections, true, nil)
|
||||
}, func(db dbx.Builder) error {
|
||||
// no revert since the configuration on the environment, on which
|
||||
// the migration was executed, could have changed via the UI/API
|
||||
return nil
|
||||
})
|
||||
}
|
||||
`
|
||||
|
||||
func migrateCollectionsHandler(app core.App, args []string) error {
|
||||
createArgs := []string{"collections_snapshot"}
|
||||
createArgs = append(createArgs, args...)
|
||||
|
||||
dao := daos.New(app.DB())
|
||||
|
||||
collections := []*models.Collection{}
|
||||
if err := dao.CollectionQuery().OrderBy("created ASC").All(&collections); err != nil {
|
||||
return fmt.Errorf("Failed to fetch migrations list: %v", err)
|
||||
}
|
||||
|
||||
serialized, err := json.MarshalIndent(collections, "\t\t", "\t")
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to serialize collections list: %v", err)
|
||||
}
|
||||
|
||||
return migrateCreateHandler(
|
||||
fmt.Sprintf(collectionsMigrateCreateTemplate, string(serialized)),
|
||||
createArgs,
|
||||
)
|
||||
}
|
||||
+24
-6
@@ -2,7 +2,6 @@ package cmd
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
@@ -11,8 +10,11 @@ import (
|
||||
|
||||
"github.com/fatih/color"
|
||||
"github.com/labstack/echo/v5/middleware"
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/apis"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/migrations"
|
||||
"github.com/pocketbase/pocketbase/migrations/logs"
|
||||
"github.com/pocketbase/pocketbase/tools/migrate"
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/crypto/acme"
|
||||
@@ -39,7 +41,7 @@ func NewServeCommand(app core.App, showStartBanner bool) *cobra.Command {
|
||||
// (or if this is the first time the init migration was executed)
|
||||
if err := app.RefreshSettings(); err != nil {
|
||||
color.Yellow("=====================================")
|
||||
color.Yellow("WARNING - Settings load error! \n%v", err)
|
||||
color.Yellow("WARNING: Settings load error! \n%v", err)
|
||||
color.Yellow("Fallback to the application defaults.")
|
||||
color.Yellow("=====================================")
|
||||
}
|
||||
@@ -76,7 +78,8 @@ func NewServeCommand(app core.App, showStartBanner bool) *cobra.Command {
|
||||
GetCertificate: certManager.GetCertificate,
|
||||
NextProtos: []string{acme.ALPNProto},
|
||||
},
|
||||
ReadTimeout: 60 * time.Second,
|
||||
ReadTimeout: 5 * time.Minute,
|
||||
ReadHeaderTimeout: 30 * time.Second,
|
||||
// WriteTimeout: 60 * time.Second, // breaks sse!
|
||||
Handler: router,
|
||||
Addr: mainAddr,
|
||||
@@ -87,10 +90,11 @@ func NewServeCommand(app core.App, showStartBanner bool) *cobra.Command {
|
||||
if httpsAddr != "" {
|
||||
schema = "https"
|
||||
}
|
||||
regular := color.New()
|
||||
bold := color.New(color.Bold).Add(color.FgGreen)
|
||||
bold.Printf("> Server started at: %s\n", color.CyanString("%s://%s", schema, serverConfig.Addr))
|
||||
fmt.Printf(" - REST API: %s\n", color.CyanString("%s://%s/api/", schema, serverConfig.Addr))
|
||||
fmt.Printf(" - Admin UI: %s\n", color.CyanString("%s://%s/_/", schema, serverConfig.Addr))
|
||||
regular.Printf(" - REST API: %s\n", color.CyanString("%s://%s/api/", schema, serverConfig.Addr))
|
||||
regular.Printf(" - Admin UI: %s\n", color.CyanString("%s://%s/_/", schema, serverConfig.Addr))
|
||||
}
|
||||
|
||||
var serveErr error
|
||||
@@ -137,8 +141,22 @@ func NewServeCommand(app core.App, showStartBanner bool) *cobra.Command {
|
||||
return command
|
||||
}
|
||||
|
||||
type migrationsConnection struct {
|
||||
DB *dbx.DB
|
||||
MigrationsList migrate.MigrationsList
|
||||
}
|
||||
|
||||
func runMigrations(app core.App) error {
|
||||
connections := migrationsConnectionsMap(app)
|
||||
connections := []migrationsConnection{
|
||||
{
|
||||
DB: app.DB(),
|
||||
MigrationsList: migrations.AppMigrations,
|
||||
},
|
||||
{
|
||||
DB: app.LogsDB(),
|
||||
MigrationsList: logs.LogsMigrations,
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range connections {
|
||||
runner, err := migrate.NewRunner(c.DB, c.MigrationsList)
|
||||
|
||||
@@ -0,0 +1,444 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/fatih/color"
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/models/schema"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// Temporary console command to update the pb_data structure to be compatible with the v0.8.0 changes.
|
||||
//
|
||||
// NB! It will be removed in v0.9+
|
||||
func NewTempUpgradeCommand(app core.App) *cobra.Command {
|
||||
command := &cobra.Command{
|
||||
Use: "upgrade",
|
||||
Short: "Upgrades your existing pb_data to be compatible with the v0.8.x changes",
|
||||
Long: `
|
||||
Upgrades your existing pb_data to be compatible with the v0.8.x changes
|
||||
Prerequisites and caveats:
|
||||
- already upgraded to v0.7.*
|
||||
- no existing users collection
|
||||
- existing profiles collection fields like email, username, verified, etc. will be renamed to username2, email2, etc.
|
||||
`,
|
||||
Run: func(command *cobra.Command, args []string) {
|
||||
if err := upgrade(app); err != nil {
|
||||
color.Red("Error: %v", err)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
return command
|
||||
}
|
||||
|
||||
func upgrade(app core.App) error {
|
||||
if _, err := app.Dao().FindCollectionByNameOrId("users"); err == nil {
|
||||
return errors.New("It seems that you've already upgraded or have an existing 'users' collection.")
|
||||
}
|
||||
|
||||
return app.Dao().RunInTransaction(func(txDao *daos.Dao) error {
|
||||
if err := migrateCollections(txDao); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := migrateUsers(app, txDao); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := resetMigrationsTable(txDao); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
bold := color.New(color.Bold).Add(color.FgGreen)
|
||||
bold.Println("The pb_data upgrade completed successfully!")
|
||||
bold.Println("You can now start the application as usual with the 'serve' command.")
|
||||
bold.Println("Please review the migrated collection API rules and fields in the Admin UI and apply the necessary changes in your client-side code.")
|
||||
fmt.Println()
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
func migrateCollections(txDao *daos.Dao) error {
|
||||
// add new collection columns
|
||||
if _, err := txDao.DB().AddColumn("_collections", "type", "TEXT DEFAULT 'base' NOT NULL").Execute(); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := txDao.DB().AddColumn("_collections", "options", "JSON DEFAULT '{}' NOT NULL").Execute(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ruleReplacements := []struct {
|
||||
old string
|
||||
new string
|
||||
}{
|
||||
{"expand", "expand2"},
|
||||
{"collecitonId", "collectionId2"},
|
||||
{"collecitonName", "collectionName2"},
|
||||
{"profile.userId", "profile.id"},
|
||||
|
||||
// @collection.*
|
||||
{"@collection.profiles.userId", "@collection.users.id"},
|
||||
{"@collection.profiles.username", "@collection.users.username2"},
|
||||
{"@collection.profiles.email", "@collection.users.email2"},
|
||||
{"@collection.profiles.emailVisibility", "@collection.users.emailVisibility2"},
|
||||
{"@collection.profiles.verified", "@collection.users.verified2"},
|
||||
{"@collection.profiles.tokenKey", "@collection.users.tokenKey2"},
|
||||
{"@collection.profiles.passwordHash", "@collection.users.passwordHash2"},
|
||||
{"@collection.profiles.lastResetSentAt", "@collection.users.lastResetSentAt2"},
|
||||
{"@collection.profiles.lastVerificationSentAt", "@collection.users.lastVerificationSentAt2"},
|
||||
{"@collection.profiles.", "@collection.users."},
|
||||
|
||||
// @request.*
|
||||
{"@request.user.profile.userId", "@request.auth.id"},
|
||||
{"@request.user.profile.username", "@request.auth.username2"},
|
||||
{"@request.user.profile.email", "@request.auth.email2"},
|
||||
{"@request.user.profile.emailVisibility", "@request.auth.emailVisibility2"},
|
||||
{"@request.user.profile.verified", "@request.auth.verified2"},
|
||||
{"@request.user.profile.tokenKey", "@request.auth.tokenKey2"},
|
||||
{"@request.user.profile.passwordHash", "@request.auth.passwordHash2"},
|
||||
{"@request.user.profile.lastResetSentAt", "@request.auth.lastResetSentAt2"},
|
||||
{"@request.user.profile.lastVerificationSentAt", "@request.auth.lastVerificationSentAt2"},
|
||||
{"@request.user.profile.", "@request.auth."},
|
||||
{"@request.user", "@request.auth"},
|
||||
}
|
||||
|
||||
collections := []*models.Collection{}
|
||||
if err := txDao.CollectionQuery().All(&collections); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, collection := range collections {
|
||||
collection.Type = models.CollectionTypeBase
|
||||
collection.NormalizeOptions()
|
||||
|
||||
// rename profile fields
|
||||
// ---
|
||||
fieldsToRename := []string{
|
||||
"collectionId",
|
||||
"collectionName",
|
||||
"expand",
|
||||
}
|
||||
if collection.Name == "profiles" {
|
||||
fieldsToRename = append(fieldsToRename,
|
||||
"username",
|
||||
"email",
|
||||
"emailVisibility",
|
||||
"verified",
|
||||
"tokenKey",
|
||||
"passwordHash",
|
||||
"lastResetSentAt",
|
||||
"lastVerificationSentAt",
|
||||
)
|
||||
}
|
||||
for _, name := range fieldsToRename {
|
||||
f := collection.Schema.GetFieldByName(name)
|
||||
if f != nil {
|
||||
color.Blue("[%s - renamed field]", collection.Name)
|
||||
color.Yellow(" - old: %s", f.Name)
|
||||
color.Green(" - new: %s2", f.Name)
|
||||
fmt.Println()
|
||||
f.Name += "2"
|
||||
}
|
||||
}
|
||||
// ---
|
||||
|
||||
// replace rule fields
|
||||
// ---
|
||||
rules := map[string]*string{
|
||||
"ListRule": collection.ListRule,
|
||||
"ViewRule": collection.ViewRule,
|
||||
"CreateRule": collection.CreateRule,
|
||||
"UpdateRule": collection.UpdateRule,
|
||||
"DeleteRule": collection.DeleteRule,
|
||||
}
|
||||
|
||||
for ruleKey, rule := range rules {
|
||||
if rule == nil || *rule == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
originalRule := *rule
|
||||
|
||||
for _, replacement := range ruleReplacements {
|
||||
re := regexp.MustCompile(regexp.QuoteMeta(replacement.old) + `\b`)
|
||||
*rule = re.ReplaceAllString(*rule, replacement.new)
|
||||
}
|
||||
|
||||
*rule = replaceReversedLikes(*rule)
|
||||
|
||||
if originalRule != *rule {
|
||||
color.Blue("[%s - replaced %s]:", collection.Name, ruleKey)
|
||||
color.Yellow(" - old: %s", strings.TrimSpace(originalRule))
|
||||
color.Green(" - new: %s", strings.TrimSpace(*rule))
|
||||
fmt.Println()
|
||||
}
|
||||
}
|
||||
// ---
|
||||
|
||||
if err := txDao.SaveCollection(collection); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func migrateUsers(app core.App, txDao *daos.Dao) error {
|
||||
color.Blue(`[merging "_users" and "profiles"]:`)
|
||||
|
||||
profilesCollection, err := txDao.FindCollectionByNameOrId("profiles")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
originalProfilesCollectionId := profilesCollection.Id
|
||||
|
||||
// change the profiles collection id to something else since we will be using
|
||||
// it for the new users collection in order to avoid renaming the storage dir
|
||||
_, idRenameErr := txDao.DB().NewQuery(fmt.Sprintf(
|
||||
`UPDATE {{_collections}}
|
||||
SET id = '%s'
|
||||
WHERE id = '%s';
|
||||
`,
|
||||
(originalProfilesCollectionId + "__old__"),
|
||||
originalProfilesCollectionId,
|
||||
)).Execute()
|
||||
if idRenameErr != nil {
|
||||
return idRenameErr
|
||||
}
|
||||
|
||||
// refresh profiles collection
|
||||
profilesCollection, err = txDao.FindCollectionByNameOrId("profiles")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
usersSchema, _ := profilesCollection.Schema.Clone()
|
||||
userIdField := usersSchema.GetFieldByName("userId")
|
||||
if userIdField != nil {
|
||||
usersSchema.RemoveField(userIdField.Id)
|
||||
}
|
||||
|
||||
usersCollection := &models.Collection{}
|
||||
usersCollection.MarkAsNew()
|
||||
usersCollection.Id = originalProfilesCollectionId
|
||||
usersCollection.Name = "users"
|
||||
usersCollection.Type = models.CollectionTypeAuth
|
||||
usersCollection.Schema = *usersSchema
|
||||
usersCollection.CreateRule = types.Pointer("")
|
||||
if profilesCollection.ListRule != nil && *profilesCollection.ListRule != "" {
|
||||
*profilesCollection.ListRule = strings.ReplaceAll(*profilesCollection.ListRule, "userId", "id")
|
||||
usersCollection.ListRule = profilesCollection.ListRule
|
||||
}
|
||||
if profilesCollection.ViewRule != nil && *profilesCollection.ViewRule != "" {
|
||||
*profilesCollection.ViewRule = strings.ReplaceAll(*profilesCollection.ViewRule, "userId", "id")
|
||||
usersCollection.ViewRule = profilesCollection.ViewRule
|
||||
}
|
||||
if profilesCollection.UpdateRule != nil && *profilesCollection.UpdateRule != "" {
|
||||
*profilesCollection.UpdateRule = strings.ReplaceAll(*profilesCollection.UpdateRule, "userId", "id")
|
||||
usersCollection.UpdateRule = profilesCollection.UpdateRule
|
||||
}
|
||||
if profilesCollection.DeleteRule != nil && *profilesCollection.DeleteRule != "" {
|
||||
*profilesCollection.DeleteRule = strings.ReplaceAll(*profilesCollection.DeleteRule, "userId", "id")
|
||||
usersCollection.DeleteRule = profilesCollection.DeleteRule
|
||||
}
|
||||
|
||||
// set auth options
|
||||
settings := app.Settings()
|
||||
authOptions := usersCollection.AuthOptions()
|
||||
authOptions.ManageRule = nil
|
||||
authOptions.AllowOAuth2Auth = true
|
||||
authOptions.AllowUsernameAuth = false
|
||||
authOptions.AllowEmailAuth = settings.EmailAuth.Enabled
|
||||
authOptions.MinPasswordLength = settings.EmailAuth.MinPasswordLength
|
||||
authOptions.OnlyEmailDomains = settings.EmailAuth.OnlyDomains
|
||||
authOptions.ExceptEmailDomains = settings.EmailAuth.ExceptDomains
|
||||
// twitter currently is the only provider that doesn't return an email
|
||||
authOptions.RequireEmail = !settings.TwitterAuth.Enabled
|
||||
|
||||
usersCollection.SetOptions(authOptions)
|
||||
|
||||
if err := txDao.SaveCollection(usersCollection); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// copy the original users
|
||||
_, usersErr := txDao.DB().NewQuery(`
|
||||
INSERT INTO {{users}} (id, created, updated, username, email, emailVisibility, verified, tokenKey, passwordHash, lastResetSentAt, lastVerificationSentAt)
|
||||
SELECT id, created, updated, ("u_" || id), email, false, verified, tokenKey, passwordHash, lastResetSentAt, lastVerificationSentAt
|
||||
FROM {{_users}};
|
||||
`).Execute()
|
||||
if usersErr != nil {
|
||||
return usersErr
|
||||
}
|
||||
|
||||
// generate the profile fields copy statements
|
||||
sets := []string{"id = p.id"}
|
||||
for _, f := range usersSchema.Fields() {
|
||||
sets = append(sets, fmt.Sprintf("%s = p.%s", f.Name, f.Name))
|
||||
}
|
||||
|
||||
// copy profile fields
|
||||
_, copyProfileErr := txDao.DB().NewQuery(fmt.Sprintf(`
|
||||
UPDATE {{users}} as u
|
||||
SET %s
|
||||
FROM {{profiles}} as p
|
||||
WHERE u.id = p.userId;
|
||||
`, strings.Join(sets, ", "))).Execute()
|
||||
if copyProfileErr != nil {
|
||||
return copyProfileErr
|
||||
}
|
||||
|
||||
profileRecords, err := txDao.FindRecordsByExpr("profiles")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// update all profiles and users fields to point to the new users collection
|
||||
collections := []*models.Collection{}
|
||||
if err := txDao.CollectionQuery().All(&collections); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, collection := range collections {
|
||||
var hasChanges bool
|
||||
|
||||
for _, f := range collection.Schema.Fields() {
|
||||
f.InitOptions()
|
||||
|
||||
if f.Type == schema.FieldTypeUser {
|
||||
if collection.Name == "profiles" && f.Name == "userId" {
|
||||
continue
|
||||
}
|
||||
|
||||
hasChanges = true
|
||||
|
||||
// change the user field to a relation field
|
||||
options, _ := f.Options.(*schema.UserOptions)
|
||||
f.Type = schema.FieldTypeRelation
|
||||
f.Options = &schema.RelationOptions{
|
||||
CollectionId: usersCollection.Id,
|
||||
MaxSelect: &options.MaxSelect,
|
||||
CascadeDelete: options.CascadeDelete,
|
||||
}
|
||||
|
||||
for _, p := range profileRecords {
|
||||
pId := p.Id
|
||||
pUserId := p.GetString("userId")
|
||||
// replace all user record id references with the profile id
|
||||
_, replaceErr := txDao.DB().NewQuery(fmt.Sprintf(`
|
||||
UPDATE %s
|
||||
SET [[%s]] = REPLACE([[%s]], '%s', '%s')
|
||||
WHERE [[%s]] LIKE ('%%%s%%');
|
||||
`, collection.Name, f.Name, f.Name, pUserId, pId, f.Name, pUserId)).Execute()
|
||||
if replaceErr != nil {
|
||||
return replaceErr
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if hasChanges {
|
||||
if err := txDao.Save(collection); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := migrateExternalAuths(txDao, originalProfilesCollectionId); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// drop _users table
|
||||
if _, err := txDao.DB().DropTable("_users").Execute(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// drop profiles table
|
||||
if _, err := txDao.DB().DropTable("profiles").Execute(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// delete profiles collection
|
||||
if err := txDao.Delete(profilesCollection); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
color.Green(` - Successfully merged "_users" and "profiles" into a new collection "users".`)
|
||||
fmt.Println()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func migrateExternalAuths(txDao *daos.Dao, userCollectionId string) error {
|
||||
_, alterErr := txDao.DB().NewQuery(`
|
||||
-- crate new externalAuths table
|
||||
CREATE TABLE {{_newExternalAuths}} (
|
||||
[[id]] TEXT PRIMARY KEY,
|
||||
[[collectionId]] TEXT NOT NULL,
|
||||
[[recordId]] TEXT NOT NULL,
|
||||
[[provider]] TEXT NOT NULL,
|
||||
[[providerId]] TEXT NOT NULL,
|
||||
[[created]] TEXT DEFAULT "" NOT NULL,
|
||||
[[updated]] TEXT DEFAULT "" NOT NULL,
|
||||
---
|
||||
FOREIGN KEY ([[collectionId]]) REFERENCES {{_collections}} ([[id]]) ON UPDATE CASCADE ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- copy all data from the old table to the new one
|
||||
INSERT INTO {{_newExternalAuths}}
|
||||
SELECT auth.id, "` + userCollectionId + `" as collectionId, [[profiles.id]] as recordId, auth.provider, auth.providerId, auth.created, auth.updated
|
||||
FROM {{_externalAuths}} auth
|
||||
INNER JOIN {{profiles}} on [[profiles.userId]] = [[auth.userId]];
|
||||
|
||||
-- drop old table
|
||||
DROP TABLE {{_externalAuths}};
|
||||
|
||||
-- rename new table
|
||||
ALTER TABLE {{_newExternalAuths}} RENAME TO {{_externalAuths}};
|
||||
|
||||
-- create named indexes
|
||||
CREATE UNIQUE INDEX _externalAuths_record_provider_idx on {{_externalAuths}} ([[collectionId]], [[recordId]], [[provider]]);
|
||||
CREATE UNIQUE INDEX _externalAuths_provider_providerId_idx on {{_externalAuths}} ([[provider]], [[providerId]]);
|
||||
`).Execute()
|
||||
|
||||
return alterErr
|
||||
}
|
||||
|
||||
func resetMigrationsTable(txDao *daos.Dao) error {
|
||||
// reset the migration state to the new init
|
||||
_, err := txDao.DB().Delete("_migrations", dbx.HashExp{
|
||||
"file": "1661586591_add_externalAuths_table.go",
|
||||
}).Execute()
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
var reverseLikeRegex = regexp.MustCompile(`(['"]\w*['"])\s*(\~|!~)\s*([\w\@\.]*)`)
|
||||
|
||||
func replaceReversedLikes(rule string) string {
|
||||
parts := reverseLikeRegex.FindAllStringSubmatch(rule, -1)
|
||||
|
||||
for _, p := range parts {
|
||||
if len(p) != 4 {
|
||||
continue
|
||||
}
|
||||
|
||||
newPart := fmt.Sprintf("%s %s %s", p[3], p[2], p[1])
|
||||
|
||||
rule = strings.ReplaceAll(rule, p[0], newPart)
|
||||
}
|
||||
|
||||
return rule
|
||||
}
|
||||
+406
-80
@@ -6,6 +6,7 @@ package core
|
||||
import (
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
"github.com/pocketbase/pocketbase/models/settings"
|
||||
"github.com/pocketbase/pocketbase/tools/filesystem"
|
||||
"github.com/pocketbase/pocketbase/tools/hook"
|
||||
"github.com/pocketbase/pocketbase/tools/mailer"
|
||||
@@ -15,6 +16,11 @@ import (
|
||||
|
||||
// App defines the main PocketBase app interface.
|
||||
type App interface {
|
||||
// Deprecated:
|
||||
// This method may get removed in the near future.
|
||||
// It is recommended to access the app db instance from app.Dao().DB() or
|
||||
// if you want more flexibility - app.Dao().ConcurrentDB() and app.Dao().NonconcurrentDB().
|
||||
//
|
||||
// DB returns the default app database instance.
|
||||
DB() *dbx.DB
|
||||
|
||||
@@ -25,6 +31,11 @@ type App interface {
|
||||
// trying to access the request logs table will result in error.
|
||||
Dao() *daos.Dao
|
||||
|
||||
// Deprecated:
|
||||
// This method may get removed in the near future.
|
||||
// It is recommended to access the logs db instance from app.LogsDao().DB() or
|
||||
// if you want more flexibility - app.LogsDao().ConcurrentDB() and app.LogsDao().NonconcurrentDB().
|
||||
//
|
||||
// LogsDB returns the app logs database instance.
|
||||
LogsDB() *dbx.DB
|
||||
|
||||
@@ -47,7 +58,7 @@ type App interface {
|
||||
IsDebug() bool
|
||||
|
||||
// Settings returns the loaded app settings.
|
||||
Settings() *Settings
|
||||
Settings() *settings.Settings
|
||||
|
||||
// Cache returns the app internal cache store.
|
||||
Cache() *store.Store[any]
|
||||
@@ -67,8 +78,14 @@ type App interface {
|
||||
// RefreshSettings reinitializes and reloads the stored application settings.
|
||||
RefreshSettings() error
|
||||
|
||||
// IsBootstrapped checks if the application was initialized
|
||||
// (aka. whether Bootstrap() was called).
|
||||
IsBootstrapped() bool
|
||||
|
||||
// Bootstrap takes care for initializing the application
|
||||
// (open db connections, load settings, etc.)
|
||||
// (open db connections, load settings, etc.).
|
||||
//
|
||||
// It will call ResetBootstrapState() if the application was already bootstrapped.
|
||||
Bootstrap() error
|
||||
|
||||
// ResetBootstrapState takes care for releasing initialized app resources
|
||||
@@ -79,37 +96,79 @@ type App interface {
|
||||
// App event hooks
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
// OnBeforeBootstrap hook is triggered before initializing the base
|
||||
// application resources (eg. before db open and initial settings load).
|
||||
OnBeforeBootstrap() *hook.Hook[*BootstrapEvent]
|
||||
|
||||
// OnAfterBootstrap hook is triggered after initializing the base
|
||||
// application resources (eg. after db open and initial settings load).
|
||||
OnAfterBootstrap() *hook.Hook[*BootstrapEvent]
|
||||
|
||||
// OnBeforeServe hook is triggered before serving the internal router (echo),
|
||||
// allowing you to adjust its options and attach new routes.
|
||||
OnBeforeServe() *hook.Hook[*ServeEvent]
|
||||
|
||||
// OnBeforeApiError hook is triggered right before sending an error API
|
||||
// response to the client, allowing you to further modify the error data
|
||||
// or to return a completely different API response (using [hook.StopPropagation]).
|
||||
OnBeforeApiError() *hook.Hook[*ApiErrorEvent]
|
||||
|
||||
// OnAfterApiError hook is triggered right after sending an error API
|
||||
// response to the client.
|
||||
// It could be used to log the final API error in external services.
|
||||
OnAfterApiError() *hook.Hook[*ApiErrorEvent]
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Dao event hooks
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
// OnModelBeforeCreate hook is triggered before inserting a new
|
||||
// entry in the DB, allowing you to modify or validate the stored data.
|
||||
OnModelBeforeCreate() *hook.Hook[*ModelEvent]
|
||||
//
|
||||
// If the optional "tags" list (table names and/or the Collection id for Record models)
|
||||
// is specified, then all event handlers registered via the created hook
|
||||
// will be triggered and called only if their event data origin matches the tags.
|
||||
OnModelBeforeCreate(tags ...string) *hook.TaggedHook[*ModelEvent]
|
||||
|
||||
// OnModelAfterCreate hook is triggered after successfully
|
||||
// inserting a new entry in the DB.
|
||||
OnModelAfterCreate() *hook.Hook[*ModelEvent]
|
||||
//
|
||||
// If the optional "tags" list (table names and/or the Collection id for Record models)
|
||||
// is specified, then all event handlers registered via the created hook
|
||||
// will be triggered and called only if their event data origin matches the tags.
|
||||
OnModelAfterCreate(tags ...string) *hook.TaggedHook[*ModelEvent]
|
||||
|
||||
// OnModelBeforeUpdate hook is triggered before updating existing
|
||||
// entry in the DB, allowing you to modify or validate the stored data.
|
||||
OnModelBeforeUpdate() *hook.Hook[*ModelEvent]
|
||||
//
|
||||
// If the optional "tags" list (table names and/or the Collection id for Record models)
|
||||
// is specified, then all event handlers registered via the created hook
|
||||
// will be triggered and called only if their event data origin matches the tags.
|
||||
OnModelBeforeUpdate(tags ...string) *hook.TaggedHook[*ModelEvent]
|
||||
|
||||
// OnModelAfterUpdate hook is triggered after successfully updating
|
||||
// existing entry in the DB.
|
||||
OnModelAfterUpdate() *hook.Hook[*ModelEvent]
|
||||
//
|
||||
// If the optional "tags" list (table names and/or the Collection id for Record models)
|
||||
// is specified, then all event handlers registered via the created hook
|
||||
// will be triggered and called only if their event data origin matches the tags.
|
||||
OnModelAfterUpdate(tags ...string) *hook.TaggedHook[*ModelEvent]
|
||||
|
||||
// OnModelBeforeDelete hook is triggered before deleting an
|
||||
// existing entry from the DB.
|
||||
OnModelBeforeDelete() *hook.Hook[*ModelEvent]
|
||||
//
|
||||
// If the optional "tags" list (table names and/or the Collection id for Record models)
|
||||
// is specified, then all event handlers registered via the created hook
|
||||
// will be triggered and called only if their event data origin matches the tags.
|
||||
OnModelBeforeDelete(tags ...string) *hook.TaggedHook[*ModelEvent]
|
||||
|
||||
// OnModelAfterDelete is triggered after successfully deleting an
|
||||
// existing entry from the DB.
|
||||
OnModelAfterDelete() *hook.Hook[*ModelEvent]
|
||||
//
|
||||
// If the optional "tags" list (table names and/or the Collection id for Record models)
|
||||
// is specified, then all event handlers registered via the created hook
|
||||
// will be triggered and called only if their event data origin matches the tags.
|
||||
OnModelAfterDelete(tags ...string) *hook.TaggedHook[*ModelEvent]
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Mailer event hooks
|
||||
@@ -126,38 +185,62 @@ type App interface {
|
||||
// admin password reset email was successfully sent.
|
||||
OnMailerAfterAdminResetPasswordSend() *hook.Hook[*MailerAdminEvent]
|
||||
|
||||
// OnMailerBeforeUserResetPasswordSend hook is triggered right before
|
||||
// sending a password reset email to a user.
|
||||
// OnMailerBeforeRecordResetPasswordSend hook is triggered right before
|
||||
// sending a password reset email to an auth record.
|
||||
//
|
||||
// Could be used to send your own custom email template if
|
||||
// [hook.StopPropagation] is returned in one of its listeners.
|
||||
OnMailerBeforeUserResetPasswordSend() *hook.Hook[*MailerUserEvent]
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnMailerBeforeRecordResetPasswordSend(tags ...string) *hook.TaggedHook[*MailerRecordEvent]
|
||||
|
||||
// OnMailerAfterUserResetPasswordSend hook is triggered after
|
||||
// a user password reset email was successfully sent.
|
||||
OnMailerAfterUserResetPasswordSend() *hook.Hook[*MailerUserEvent]
|
||||
// OnMailerAfterRecordResetPasswordSend hook is triggered after
|
||||
// an auth record password reset email was successfully sent.
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnMailerAfterRecordResetPasswordSend(tags ...string) *hook.TaggedHook[*MailerRecordEvent]
|
||||
|
||||
// OnMailerBeforeUserVerificationSend hook is triggered right before
|
||||
// sending a verification email to a user.
|
||||
// OnMailerBeforeRecordVerificationSend hook is triggered right before
|
||||
// sending a verification email to an auth record.
|
||||
//
|
||||
// Could be used to send your own custom email template if
|
||||
// [hook.StopPropagation] is returned in one of its listeners.
|
||||
OnMailerBeforeUserVerificationSend() *hook.Hook[*MailerUserEvent]
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnMailerBeforeRecordVerificationSend(tags ...string) *hook.TaggedHook[*MailerRecordEvent]
|
||||
|
||||
// OnMailerAfterUserVerificationSend hook is triggered after a user
|
||||
// verification email was successfully sent.
|
||||
OnMailerAfterUserVerificationSend() *hook.Hook[*MailerUserEvent]
|
||||
// OnMailerAfterRecordVerificationSend hook is triggered after a
|
||||
// verification email was successfully sent to an auth record.
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnMailerAfterRecordVerificationSend(tags ...string) *hook.TaggedHook[*MailerRecordEvent]
|
||||
|
||||
// OnMailerBeforeUserChangeEmailSend hook is triggered right before
|
||||
// sending a confirmation new address email to a a user.
|
||||
// OnMailerBeforeRecordChangeEmailSend hook is triggered right before
|
||||
// sending a confirmation new address email to an auth record.
|
||||
//
|
||||
// Could be used to send your own custom email template if
|
||||
// [hook.StopPropagation] is returned in one of its listeners.
|
||||
OnMailerBeforeUserChangeEmailSend() *hook.Hook[*MailerUserEvent]
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnMailerBeforeRecordChangeEmailSend(tags ...string) *hook.TaggedHook[*MailerRecordEvent]
|
||||
|
||||
// OnMailerAfterUserChangeEmailSend hook is triggered after a user
|
||||
// change address email was successfully sent.
|
||||
OnMailerAfterUserChangeEmailSend() *hook.Hook[*MailerUserEvent]
|
||||
// OnMailerAfterRecordChangeEmailSend hook is triggered after a
|
||||
// verification email was successfully sent to an auth record.
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnMailerAfterRecordChangeEmailSend(tags ...string) *hook.TaggedHook[*MailerRecordEvent]
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Realtime API event hooks
|
||||
@@ -167,6 +250,21 @@ type App interface {
|
||||
// the SSE client connection.
|
||||
OnRealtimeConnectRequest() *hook.Hook[*RealtimeConnectEvent]
|
||||
|
||||
// OnRealtimeDisconnectRequest hook is triggered on disconnected/interrupted
|
||||
// SSE client connection.
|
||||
OnRealtimeDisconnectRequest() *hook.Hook[*RealtimeDisconnectEvent]
|
||||
|
||||
// OnRealtimeBeforeMessage hook is triggered right before sending
|
||||
// an SSE message to a client.
|
||||
//
|
||||
// Returning [hook.StopPropagation] will prevent sending the message.
|
||||
// Returning any other non-nil error will close the realtime connection.
|
||||
OnRealtimeBeforeMessageSend() *hook.Hook[*RealtimeMessageEvent]
|
||||
|
||||
// OnRealtimeBeforeMessage hook is triggered right after sending
|
||||
// an SSE message to a client.
|
||||
OnRealtimeAfterMessageSend() *hook.Hook[*RealtimeMessageEvent]
|
||||
|
||||
// OnRealtimeBeforeSubscribeRequest hook is triggered before changing
|
||||
// the client subscriptions, allowing you to further validate and
|
||||
// modify the submitted change.
|
||||
@@ -207,7 +305,7 @@ type App interface {
|
||||
//
|
||||
// Could be used to validate or modify the file response before
|
||||
// returning it to the client.
|
||||
OnFileDownloadRequest() *hook.Hook[*FileDownloadEvent]
|
||||
OnFileDownloadRequest(tags ...string) *hook.TaggedHook[*FileDownloadEvent]
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Admin API event hooks
|
||||
@@ -263,117 +361,345 @@ type App interface {
|
||||
// authenticated admin data and token.
|
||||
OnAdminAuthRequest() *hook.Hook[*AdminAuthEvent]
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// User API event hooks
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
// OnUsersListRequest hook is triggered on each API Users list request.
|
||||
// OnAdminBeforeAuthWithPasswordRequest hook is triggered before each Admin
|
||||
// auth with password API request (after request data load and before password validation).
|
||||
//
|
||||
// Could be used to validate or modify the response before returning it to the client.
|
||||
OnUsersListRequest() *hook.Hook[*UsersListEvent]
|
||||
// Could be used to implement for example a custom password validation
|
||||
// or to locate a different Admin identity (by assigning [AdminAuthWithPasswordEvent.Admin]).
|
||||
OnAdminBeforeAuthWithPasswordRequest() *hook.Hook[*AdminAuthWithPasswordEvent]
|
||||
|
||||
// OnUserViewRequest hook is triggered on each API User view request.
|
||||
// OnAdminAfterAuthWithPasswordRequest hook is triggered after each
|
||||
// successful Admin auth with password API request.
|
||||
OnAdminAfterAuthWithPasswordRequest() *hook.Hook[*AdminAuthWithPasswordEvent]
|
||||
|
||||
// OnAdminBeforeAuthRefreshRequest hook is triggered before each Admin
|
||||
// auth refresh API request (right before generating a new auth token).
|
||||
//
|
||||
// Could be used to validate or modify the response before returning it to the client.
|
||||
OnUserViewRequest() *hook.Hook[*UserViewEvent]
|
||||
// Could be used to additionally validate the request data or implement
|
||||
// completely different auth refresh behavior (returning [hook.StopPropagation]).
|
||||
OnAdminBeforeAuthRefreshRequest() *hook.Hook[*AdminAuthRefreshEvent]
|
||||
|
||||
// OnUserBeforeCreateRequest hook is triggered before each API User
|
||||
// create request (after request data load and before model persistence).
|
||||
// OnAdminAfterAuthRefreshRequest hook is triggered after each
|
||||
// successful auth refresh API request (right after generating a new auth token).
|
||||
OnAdminAfterAuthRefreshRequest() *hook.Hook[*AdminAuthRefreshEvent]
|
||||
|
||||
// OnAdminBeforeRequestPasswordResetRequest hook is triggered before each Admin
|
||||
// request password reset API request (after request data load and before sending the reset email).
|
||||
//
|
||||
// Could be used to additionally validate the request data or implement
|
||||
// completely different password reset behavior (returning [hook.StopPropagation]).
|
||||
OnAdminBeforeRequestPasswordResetRequest() *hook.Hook[*AdminRequestPasswordResetEvent]
|
||||
|
||||
// OnAdminAfterRequestPasswordResetRequest hook is triggered after each
|
||||
// successful request password reset API request.
|
||||
OnAdminAfterRequestPasswordResetRequest() *hook.Hook[*AdminRequestPasswordResetEvent]
|
||||
|
||||
// OnAdminBeforeConfirmPasswordResetRequest hook is triggered before each Admin
|
||||
// confirm password reset API request (after request data load and before persistence).
|
||||
//
|
||||
// Could be used to additionally validate the request data or implement
|
||||
// completely different persistence behavior (returning [hook.StopPropagation]).
|
||||
OnUserBeforeCreateRequest() *hook.Hook[*UserCreateEvent]
|
||||
OnAdminBeforeConfirmPasswordResetRequest() *hook.Hook[*AdminConfirmPasswordResetEvent]
|
||||
|
||||
// OnUserAfterCreateRequest hook is triggered after each
|
||||
// successful API User create request.
|
||||
OnUserAfterCreateRequest() *hook.Hook[*UserCreateEvent]
|
||||
// OnAdminAfterConfirmPasswordResetRequest hook is triggered after each
|
||||
// successful confirm password reset API request.
|
||||
OnAdminAfterConfirmPasswordResetRequest() *hook.Hook[*AdminConfirmPasswordResetEvent]
|
||||
|
||||
// OnUserBeforeUpdateRequest hook is triggered before each API User
|
||||
// update request (after request data load and before model persistence).
|
||||
// ---------------------------------------------------------------
|
||||
// Record Auth API event hooks
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
// OnRecordAuthRequest hook is triggered on each successful API
|
||||
// record authentication request (sign-in, token refresh, etc.).
|
||||
//
|
||||
// Could be used to additionally validate or modify the authenticated
|
||||
// record data and token.
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnRecordAuthRequest(tags ...string) *hook.TaggedHook[*RecordAuthEvent]
|
||||
|
||||
// OnRecordBeforeAuthWithPasswordRequest hook is triggered before each Record
|
||||
// auth with password API request (after request data load and before password validation).
|
||||
//
|
||||
// Could be used to implement for example a custom password validation
|
||||
// or to locate a different Record identity (by assigning [RecordAuthWithPasswordEvent.Record]).
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnRecordBeforeAuthWithPasswordRequest(tags ...string) *hook.TaggedHook[*RecordAuthWithPasswordEvent]
|
||||
|
||||
// OnRecordAfterAuthWithPasswordRequest hook is triggered after each
|
||||
// successful Record auth with password API request.
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnRecordAfterAuthWithPasswordRequest(tags ...string) *hook.TaggedHook[*RecordAuthWithPasswordEvent]
|
||||
|
||||
// OnRecordBeforeAuthWithOAuth2Request hook is triggered before each Record
|
||||
// OAuth2 sign-in/sign-up API request (after token exchange and before external provider linking).
|
||||
//
|
||||
// If the [RecordAuthWithOAuth2Event.Record] is nil, then the OAuth2
|
||||
// request will try to create a new auth Record.
|
||||
//
|
||||
// To assign or link a different existing record model you can
|
||||
// overwrite/modify the [RecordAuthWithOAuth2Event.Record] field.
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnRecordBeforeAuthWithOAuth2Request(tags ...string) *hook.TaggedHook[*RecordAuthWithOAuth2Event]
|
||||
|
||||
// OnRecordAfterAuthWithOAuth2Request hook is triggered after each
|
||||
// successful Record OAuth2 API request.
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnRecordAfterAuthWithOAuth2Request(tags ...string) *hook.TaggedHook[*RecordAuthWithOAuth2Event]
|
||||
|
||||
// OnRecordBeforeAuthRefreshRequest hook is triggered before each Record
|
||||
// auth refresh API request (right before generating a new auth token).
|
||||
//
|
||||
// Could be used to additionally validate the request data or implement
|
||||
// completely different auth refresh behavior (returning [hook.StopPropagation]).
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnRecordBeforeAuthRefreshRequest(tags ...string) *hook.TaggedHook[*RecordAuthRefreshEvent]
|
||||
|
||||
// OnRecordAfterAuthRefreshRequest hook is triggered after each
|
||||
// successful auth refresh API request (right after generating a new auth token).
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnRecordAfterAuthRefreshRequest(tags ...string) *hook.TaggedHook[*RecordAuthRefreshEvent]
|
||||
|
||||
// OnRecordBeforeRequestPasswordResetRequest hook is triggered before each Record
|
||||
// request password reset API request (after request data load and before sending the reset email).
|
||||
//
|
||||
// Could be used to additionally validate the request data or implement
|
||||
// completely different password reset behavior (returning [hook.StopPropagation]).
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnRecordBeforeRequestPasswordResetRequest(tags ...string) *hook.TaggedHook[*RecordRequestPasswordResetEvent]
|
||||
|
||||
// OnRecordAfterRequestPasswordResetRequest hook is triggered after each
|
||||
// successful request password reset API request.
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnRecordAfterRequestPasswordResetRequest(tags ...string) *hook.TaggedHook[*RecordRequestPasswordResetEvent]
|
||||
|
||||
// OnRecordBeforeConfirmPasswordResetRequest hook is triggered before each Record
|
||||
// confirm password reset API request (after request data load and before persistence).
|
||||
//
|
||||
// Could be used to additionally validate the request data or implement
|
||||
// completely different persistence behavior (returning [hook.StopPropagation]).
|
||||
OnUserBeforeUpdateRequest() *hook.Hook[*UserUpdateEvent]
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnRecordBeforeConfirmPasswordResetRequest(tags ...string) *hook.TaggedHook[*RecordConfirmPasswordResetEvent]
|
||||
|
||||
// OnUserAfterUpdateRequest hook is triggered after each
|
||||
// successful API User update request.
|
||||
OnUserAfterUpdateRequest() *hook.Hook[*UserUpdateEvent]
|
||||
// OnRecordAfterConfirmPasswordResetRequest hook is triggered after each
|
||||
// successful confirm password reset API request.
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnRecordAfterConfirmPasswordResetRequest(tags ...string) *hook.TaggedHook[*RecordConfirmPasswordResetEvent]
|
||||
|
||||
// OnUserBeforeDeleteRequest hook is triggered before each API User
|
||||
// delete request (after model load and before actual deletion).
|
||||
// OnRecordBeforeRequestVerificationRequest hook is triggered before each Record
|
||||
// request verification API request (after request data load and before sending the verification email).
|
||||
//
|
||||
// Could be used to additionally validate the loaded request data or implement
|
||||
// completely different verification behavior (returning [hook.StopPropagation]).
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnRecordBeforeRequestVerificationRequest(tags ...string) *hook.TaggedHook[*RecordRequestVerificationEvent]
|
||||
|
||||
// OnRecordAfterRequestVerificationRequest hook is triggered after each
|
||||
// successful request verification API request.
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnRecordAfterRequestVerificationRequest(tags ...string) *hook.TaggedHook[*RecordRequestVerificationEvent]
|
||||
|
||||
// OnRecordBeforeConfirmVerificationRequest hook is triggered before each Record
|
||||
// confirm verification API request (after request data load and before persistence).
|
||||
//
|
||||
// Could be used to additionally validate the request data or implement
|
||||
// completely different persistence behavior (returning [hook.StopPropagation]).
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnRecordBeforeConfirmVerificationRequest(tags ...string) *hook.TaggedHook[*RecordConfirmVerificationEvent]
|
||||
|
||||
// OnRecordAfterConfirmVerificationRequest hook is triggered after each
|
||||
// successful confirm verification API request.
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnRecordAfterConfirmVerificationRequest(tags ...string) *hook.TaggedHook[*RecordConfirmVerificationEvent]
|
||||
|
||||
// OnRecordBeforeRequestEmailChangeRequest hook is triggered before each Record request email change API request
|
||||
// (after request data load and before sending the email link to confirm the change).
|
||||
//
|
||||
// Could be used to additionally validate the request data or implement
|
||||
// completely different request email change behavior (returning [hook.StopPropagation]).
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnRecordBeforeRequestEmailChangeRequest(tags ...string) *hook.TaggedHook[*RecordRequestEmailChangeEvent]
|
||||
|
||||
// OnRecordAfterRequestEmailChangeRequest hook is triggered after each
|
||||
// successful request email change API request.
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnRecordAfterRequestEmailChangeRequest(tags ...string) *hook.TaggedHook[*RecordRequestEmailChangeEvent]
|
||||
|
||||
// OnRecordBeforeConfirmEmailChangeRequest hook is triggered before each Record
|
||||
// confirm email change API request (after request data load and before persistence).
|
||||
//
|
||||
// Could be used to additionally validate the request data or implement
|
||||
// completely different persistence behavior (returning [hook.StopPropagation]).
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnRecordBeforeConfirmEmailChangeRequest(tags ...string) *hook.TaggedHook[*RecordConfirmEmailChangeEvent]
|
||||
|
||||
// OnRecordAfterConfirmEmailChangeRequest hook is triggered after each
|
||||
// successful confirm email change API request.
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnRecordAfterConfirmEmailChangeRequest(tags ...string) *hook.TaggedHook[*RecordConfirmEmailChangeEvent]
|
||||
|
||||
// OnRecordListExternalAuthsRequest hook is triggered on each API record external auths list request.
|
||||
//
|
||||
// Could be used to validate or modify the response before returning it to the client.
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnRecordListExternalAuthsRequest(tags ...string) *hook.TaggedHook[*RecordListExternalAuthsEvent]
|
||||
|
||||
// OnRecordBeforeUnlinkExternalAuthRequest hook is triggered before each API record
|
||||
// external auth unlink request (after models load and before the actual relation deletion).
|
||||
//
|
||||
// Could be used to additionally validate the request data or implement
|
||||
// completely different delete behavior (returning [hook.StopPropagation]).
|
||||
OnUserBeforeDeleteRequest() *hook.Hook[*UserDeleteEvent]
|
||||
|
||||
// OnUserAfterDeleteRequest hook is triggered after each
|
||||
// successful API User delete request.
|
||||
OnUserAfterDeleteRequest() *hook.Hook[*UserDeleteEvent]
|
||||
|
||||
// OnUserAuthRequest hook is triggered on each successful API User
|
||||
// authentication request (sign-in, token refresh, etc.).
|
||||
//
|
||||
// Could be used to additionally validate or modify the
|
||||
// authenticated user data and token.
|
||||
OnUserAuthRequest() *hook.Hook[*UserAuthEvent]
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnRecordBeforeUnlinkExternalAuthRequest(tags ...string) *hook.TaggedHook[*RecordUnlinkExternalAuthEvent]
|
||||
|
||||
// OnUserBeforeOauth2Register hook is triggered before each User OAuth2
|
||||
// authentication request (when the client config has enabled new users registration).
|
||||
// OnRecordAfterUnlinkExternalAuthRequest hook is triggered after each
|
||||
// successful API record external auth unlink request.
|
||||
//
|
||||
// Could be used to additionally validate or modify the new user
|
||||
// before persisting in the DB.
|
||||
OnUserBeforeOauth2Register() *hook.Hook[*UserOauth2RegisterEvent]
|
||||
|
||||
// OnUserAfterOauth2Register hook is triggered after each successful User
|
||||
// OAuth2 authentication sign-up request (right after the new user persistence).
|
||||
OnUserAfterOauth2Register() *hook.Hook[*UserOauth2RegisterEvent]
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnRecordAfterUnlinkExternalAuthRequest(tags ...string) *hook.TaggedHook[*RecordUnlinkExternalAuthEvent]
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Record API event hooks
|
||||
// Record CRUD API event hooks
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
// OnRecordsListRequest hook is triggered on each API Records list request.
|
||||
//
|
||||
// Could be used to validate or modify the response before returning it to the client.
|
||||
OnRecordsListRequest() *hook.Hook[*RecordsListEvent]
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnRecordsListRequest(tags ...string) *hook.TaggedHook[*RecordsListEvent]
|
||||
|
||||
// OnRecordViewRequest hook is triggered on each API Record view request.
|
||||
//
|
||||
// Could be used to validate or modify the response before returning it to the client.
|
||||
OnRecordViewRequest() *hook.Hook[*RecordViewEvent]
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnRecordViewRequest(tags ...string) *hook.TaggedHook[*RecordViewEvent]
|
||||
|
||||
// OnRecordBeforeCreateRequest hook is triggered before each API Record
|
||||
// create request (after request data load and before model persistence).
|
||||
//
|
||||
// Could be used to additionally validate the request data or implement
|
||||
// completely different persistence behavior (returning [hook.StopPropagation]).
|
||||
OnRecordBeforeCreateRequest() *hook.Hook[*RecordCreateEvent]
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnRecordBeforeCreateRequest(tags ...string) *hook.TaggedHook[*RecordCreateEvent]
|
||||
|
||||
// OnRecordAfterCreateRequest hook is triggered after each
|
||||
// successful API Record create request.
|
||||
OnRecordAfterCreateRequest() *hook.Hook[*RecordCreateEvent]
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnRecordAfterCreateRequest(tags ...string) *hook.TaggedHook[*RecordCreateEvent]
|
||||
|
||||
// OnRecordBeforeUpdateRequest hook is triggered before each API Record
|
||||
// update request (after request data load and before model persistence).
|
||||
//
|
||||
// Could be used to additionally validate the request data or implement
|
||||
// completely different persistence behavior (returning [hook.StopPropagation]).
|
||||
OnRecordBeforeUpdateRequest() *hook.Hook[*RecordUpdateEvent]
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnRecordBeforeUpdateRequest(tags ...string) *hook.TaggedHook[*RecordUpdateEvent]
|
||||
|
||||
// OnRecordAfterUpdateRequest hook is triggered after each
|
||||
// successful API Record update request.
|
||||
OnRecordAfterUpdateRequest() *hook.Hook[*RecordUpdateEvent]
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnRecordAfterUpdateRequest(tags ...string) *hook.TaggedHook[*RecordUpdateEvent]
|
||||
|
||||
// OnRecordBeforeDeleteRequest hook is triggered before each API Record
|
||||
// delete request (after model load and before actual deletion).
|
||||
//
|
||||
// Could be used to additionally validate the request data or implement
|
||||
// completely different delete behavior (returning [hook.StopPropagation]).
|
||||
OnRecordBeforeDeleteRequest() *hook.Hook[*RecordDeleteEvent]
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnRecordBeforeDeleteRequest(tags ...string) *hook.TaggedHook[*RecordDeleteEvent]
|
||||
|
||||
// OnRecordAfterDeleteRequest hook is triggered after each
|
||||
// successful API Record delete request.
|
||||
OnRecordAfterDeleteRequest() *hook.Hook[*RecordDeleteEvent]
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnRecordAfterDeleteRequest(tags ...string) *hook.TaggedHook[*RecordDeleteEvent]
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Collection API event hooks
|
||||
|
||||
+493
-239
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,63 @@
|
||||
package core_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
)
|
||||
|
||||
func TestBaseAppRefreshSettings(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
// cleanup all stored settings
|
||||
if _, err := app.DB().NewQuery("DELETE from _params;").Execute(); err != nil {
|
||||
t.Fatalf("Failed to delete all test settings: %v", err)
|
||||
}
|
||||
|
||||
// check if the new settings are saved in the db
|
||||
app.ResetEventCalls()
|
||||
if err := app.RefreshSettings(); err != nil {
|
||||
t.Fatal("Failed to refresh the settings after delete")
|
||||
}
|
||||
testEventCalls(t, app, map[string]int{
|
||||
"OnModelBeforeCreate": 1,
|
||||
"OnModelAfterCreate": 1,
|
||||
})
|
||||
param, err := app.Dao().FindParamByKey(models.ParamAppSettings)
|
||||
if err != nil {
|
||||
t.Fatalf("Expected new settings to be persisted, got %v", err)
|
||||
}
|
||||
|
||||
// change the db entry and refresh the app settings (ensure that there was no db update)
|
||||
param.Value = types.JsonRaw([]byte(`{"example": 123}`))
|
||||
if err := app.Dao().SaveParam(param.Key, param.Value); err != nil {
|
||||
t.Fatalf("Failed to update the test settings: %v", err)
|
||||
}
|
||||
app.ResetEventCalls()
|
||||
if err := app.RefreshSettings(); err != nil {
|
||||
t.Fatalf("Failed to refresh the app settings: %v", err)
|
||||
}
|
||||
testEventCalls(t, app, nil)
|
||||
|
||||
// try to refresh again without doing any changes
|
||||
app.ResetEventCalls()
|
||||
if err := app.RefreshSettings(); err != nil {
|
||||
t.Fatalf("Failed to refresh the app settings without change: %v", err)
|
||||
}
|
||||
testEventCalls(t, app, nil)
|
||||
}
|
||||
|
||||
func testEventCalls(t *testing.T, app *tests.TestApp, events map[string]int) {
|
||||
if len(events) != len(app.EventCalls) {
|
||||
t.Fatalf("Expected events doesn't match: \n%v, \ngot \n%v", events, app.EventCalls)
|
||||
}
|
||||
|
||||
for name, total := range events {
|
||||
if v, ok := app.EventCalls[name]; !ok || v != total {
|
||||
t.Fatalf("Expected events doesn't exist or match: \n%v, \ngot \n%v", events, app.EventCalls)
|
||||
}
|
||||
}
|
||||
}
|
||||
+39
-239
@@ -11,7 +11,11 @@ func TestNewBaseApp(t *testing.T) {
|
||||
const testDataDir = "./pb_base_app_test_data_dir/"
|
||||
defer os.RemoveAll(testDataDir)
|
||||
|
||||
app := NewBaseApp(testDataDir, "test_env", true)
|
||||
app := NewBaseApp(&BaseAppConfig{
|
||||
DataDir: testDataDir,
|
||||
EncryptionEnv: "test_env",
|
||||
IsDebug: true,
|
||||
})
|
||||
|
||||
if app.dataDir != testDataDir {
|
||||
t.Fatalf("expected dataDir %q, got %q", testDataDir, app.dataDir)
|
||||
@@ -42,14 +46,26 @@ func TestBaseAppBootstrap(t *testing.T) {
|
||||
const testDataDir = "./pb_base_app_test_data_dir/"
|
||||
defer os.RemoveAll(testDataDir)
|
||||
|
||||
app := NewBaseApp(testDataDir, "pb_test_env", false)
|
||||
app := NewBaseApp(&BaseAppConfig{
|
||||
DataDir: testDataDir,
|
||||
EncryptionEnv: "pb_test_env",
|
||||
IsDebug: false,
|
||||
})
|
||||
defer app.ResetBootstrapState()
|
||||
|
||||
if app.IsBootstrapped() {
|
||||
t.Fatal("Didn't expect the application to be bootstrapped.")
|
||||
}
|
||||
|
||||
// bootstrap
|
||||
if err := app.Bootstrap(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if !app.IsBootstrapped() {
|
||||
t.Fatal("Expected the application to be bootstrapped.")
|
||||
}
|
||||
|
||||
if stat, err := os.Stat(testDataDir); err != nil || !stat.IsDir() {
|
||||
t.Fatal("Expected test data directory to be created.")
|
||||
}
|
||||
@@ -112,29 +128,33 @@ func TestBaseAppGetters(t *testing.T) {
|
||||
const testDataDir = "./pb_base_app_test_data_dir/"
|
||||
defer os.RemoveAll(testDataDir)
|
||||
|
||||
app := NewBaseApp(testDataDir, "pb_test_env", false)
|
||||
app := NewBaseApp(&BaseAppConfig{
|
||||
DataDir: testDataDir,
|
||||
EncryptionEnv: "pb_test_env",
|
||||
IsDebug: false,
|
||||
})
|
||||
defer app.ResetBootstrapState()
|
||||
|
||||
if err := app.Bootstrap(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if app.db != app.DB() {
|
||||
t.Fatalf("Expected app.DB %v, got %v", app.DB(), app.db)
|
||||
}
|
||||
|
||||
if app.dao != app.Dao() {
|
||||
t.Fatalf("Expected app.Dao %v, got %v", app.Dao(), app.dao)
|
||||
}
|
||||
|
||||
if app.logsDB != app.LogsDB() {
|
||||
t.Fatalf("Expected app.LogsDB %v, got %v", app.LogsDB(), app.logsDB)
|
||||
if app.dao.ConcurrentDB() != app.DB() {
|
||||
t.Fatalf("Expected app.DB %v, got %v", app.DB(), app.dao.ConcurrentDB())
|
||||
}
|
||||
|
||||
if app.logsDao != app.LogsDao() {
|
||||
t.Fatalf("Expected app.LogsDao %v, got %v", app.LogsDao(), app.logsDao)
|
||||
}
|
||||
|
||||
if app.logsDao.ConcurrentDB() != app.LogsDB() {
|
||||
t.Fatalf("Expected app.LogsDB %v, got %v", app.LogsDB(), app.logsDao.ConcurrentDB())
|
||||
}
|
||||
|
||||
if app.dataDir != app.DataDir() {
|
||||
t.Fatalf("Expected app.DataDir %v, got %v", app.DataDir(), app.dataDir)
|
||||
}
|
||||
@@ -162,241 +182,17 @@ func TestBaseAppGetters(t *testing.T) {
|
||||
if app.onBeforeServe != app.OnBeforeServe() || app.OnBeforeServe() == nil {
|
||||
t.Fatalf("Getter app.OnBeforeServe does not match or nil (%v vs %v)", app.OnBeforeServe(), app.onBeforeServe)
|
||||
}
|
||||
|
||||
if app.onModelBeforeCreate != app.OnModelBeforeCreate() || app.OnModelBeforeCreate() == nil {
|
||||
t.Fatalf("Getter app.OnModelBeforeCreate does not match or nil (%v vs %v)", app.OnModelBeforeCreate(), app.onModelBeforeCreate)
|
||||
}
|
||||
|
||||
if app.onModelAfterCreate != app.OnModelAfterCreate() || app.OnModelAfterCreate() == nil {
|
||||
t.Fatalf("Getter app.OnModelAfterCreate does not match or nil (%v vs %v)", app.OnModelAfterCreate(), app.onModelAfterCreate)
|
||||
}
|
||||
|
||||
if app.onModelBeforeUpdate != app.OnModelBeforeUpdate() || app.OnModelBeforeUpdate() == nil {
|
||||
t.Fatalf("Getter app.OnModelBeforeUpdate does not match or nil (%v vs %v)", app.OnModelBeforeUpdate(), app.onModelBeforeUpdate)
|
||||
}
|
||||
|
||||
if app.onModelAfterUpdate != app.OnModelAfterUpdate() || app.OnModelAfterUpdate() == nil {
|
||||
t.Fatalf("Getter app.OnModelAfterUpdate does not match or nil (%v vs %v)", app.OnModelAfterUpdate(), app.onModelAfterUpdate)
|
||||
}
|
||||
|
||||
if app.onModelBeforeDelete != app.OnModelBeforeDelete() || app.OnModelBeforeDelete() == nil {
|
||||
t.Fatalf("Getter app.OnModelBeforeDelete does not match or nil (%v vs %v)", app.OnModelBeforeDelete(), app.onModelBeforeDelete)
|
||||
}
|
||||
|
||||
if app.onModelAfterDelete != app.OnModelAfterDelete() || app.OnModelAfterDelete() == nil {
|
||||
t.Fatalf("Getter app.OnModelAfterDelete does not match or nil (%v vs %v)", app.OnModelAfterDelete(), app.onModelAfterDelete)
|
||||
}
|
||||
|
||||
if app.onMailerBeforeAdminResetPasswordSend != app.OnMailerBeforeAdminResetPasswordSend() || app.OnMailerBeforeAdminResetPasswordSend() == nil {
|
||||
t.Fatalf("Getter app.OnMailerBeforeAdminResetPasswordSend does not match or nil (%v vs %v)", app.OnMailerBeforeAdminResetPasswordSend(), app.onMailerBeforeAdminResetPasswordSend)
|
||||
}
|
||||
|
||||
if app.onMailerAfterAdminResetPasswordSend != app.OnMailerAfterAdminResetPasswordSend() || app.OnMailerAfterAdminResetPasswordSend() == nil {
|
||||
t.Fatalf("Getter app.OnMailerAfterAdminResetPasswordSend does not match or nil (%v vs %v)", app.OnMailerAfterAdminResetPasswordSend(), app.onMailerAfterAdminResetPasswordSend)
|
||||
}
|
||||
|
||||
if app.onMailerBeforeUserResetPasswordSend != app.OnMailerBeforeUserResetPasswordSend() || app.OnMailerBeforeUserResetPasswordSend() == nil {
|
||||
t.Fatalf("Getter app.OnMailerBeforeUserResetPasswordSend does not match or nil (%v vs %v)", app.OnMailerBeforeUserResetPasswordSend(), app.onMailerBeforeUserResetPasswordSend)
|
||||
}
|
||||
|
||||
if app.onMailerAfterUserResetPasswordSend != app.OnMailerAfterUserResetPasswordSend() || app.OnMailerAfterUserResetPasswordSend() == nil {
|
||||
t.Fatalf("Getter app.OnMailerAfterUserResetPasswordSend does not match or nil (%v vs %v)", app.OnMailerAfterUserResetPasswordSend(), app.onMailerAfterUserResetPasswordSend)
|
||||
}
|
||||
|
||||
if app.onMailerBeforeUserVerificationSend != app.OnMailerBeforeUserVerificationSend() || app.OnMailerBeforeUserVerificationSend() == nil {
|
||||
t.Fatalf("Getter app.OnMailerBeforeUserVerificationSend does not match or nil (%v vs %v)", app.OnMailerBeforeUserVerificationSend(), app.onMailerBeforeUserVerificationSend)
|
||||
}
|
||||
|
||||
if app.onMailerAfterUserVerificationSend != app.OnMailerAfterUserVerificationSend() || app.OnMailerAfterUserVerificationSend() == nil {
|
||||
t.Fatalf("Getter app.OnMailerAfterUserVerificationSend does not match or nil (%v vs %v)", app.OnMailerAfterUserVerificationSend(), app.onMailerAfterUserVerificationSend)
|
||||
}
|
||||
|
||||
if app.onMailerBeforeUserChangeEmailSend != app.OnMailerBeforeUserChangeEmailSend() || app.OnMailerBeforeUserChangeEmailSend() == nil {
|
||||
t.Fatalf("Getter app.OnMailerBeforeUserChangeEmailSend does not match or nil (%v vs %v)", app.OnMailerBeforeUserChangeEmailSend(), app.onMailerBeforeUserChangeEmailSend)
|
||||
}
|
||||
|
||||
if app.onMailerAfterUserChangeEmailSend != app.OnMailerAfterUserChangeEmailSend() || app.OnMailerAfterUserChangeEmailSend() == nil {
|
||||
t.Fatalf("Getter app.OnMailerAfterUserChangeEmailSend does not match or nil (%v vs %v)", app.OnMailerAfterUserChangeEmailSend(), app.onMailerAfterUserChangeEmailSend)
|
||||
}
|
||||
|
||||
if app.onRealtimeConnectRequest != app.OnRealtimeConnectRequest() || app.OnRealtimeConnectRequest() == nil {
|
||||
t.Fatalf("Getter app.OnRealtimeConnectRequest does not match or nil (%v vs %v)", app.OnRealtimeConnectRequest(), app.onRealtimeConnectRequest)
|
||||
}
|
||||
|
||||
if app.onRealtimeBeforeSubscribeRequest != app.OnRealtimeBeforeSubscribeRequest() || app.OnRealtimeBeforeSubscribeRequest() == nil {
|
||||
t.Fatalf("Getter app.OnRealtimeBeforeSubscribeRequest does not match or nil (%v vs %v)", app.OnRealtimeBeforeSubscribeRequest(), app.onRealtimeBeforeSubscribeRequest)
|
||||
}
|
||||
|
||||
if app.onRealtimeAfterSubscribeRequest != app.OnRealtimeAfterSubscribeRequest() || app.OnRealtimeAfterSubscribeRequest() == nil {
|
||||
t.Fatalf("Getter app.OnRealtimeAfterSubscribeRequest does not match or nil (%v vs %v)", app.OnRealtimeAfterSubscribeRequest(), app.onRealtimeAfterSubscribeRequest)
|
||||
}
|
||||
|
||||
if app.onSettingsListRequest != app.OnSettingsListRequest() || app.OnSettingsListRequest() == nil {
|
||||
t.Fatalf("Getter app.OnSettingsListRequest does not match or nil (%v vs %v)", app.OnSettingsListRequest(), app.onSettingsListRequest)
|
||||
}
|
||||
|
||||
if app.onSettingsBeforeUpdateRequest != app.OnSettingsBeforeUpdateRequest() || app.OnSettingsBeforeUpdateRequest() == nil {
|
||||
t.Fatalf("Getter app.OnSettingsBeforeUpdateRequest does not match or nil (%v vs %v)", app.OnSettingsBeforeUpdateRequest(), app.onSettingsBeforeUpdateRequest)
|
||||
}
|
||||
|
||||
if app.onSettingsAfterUpdateRequest != app.OnSettingsAfterUpdateRequest() || app.OnSettingsAfterUpdateRequest() == nil {
|
||||
t.Fatalf("Getter app.OnSettingsAfterUpdateRequest does not match or nil (%v vs %v)", app.OnSettingsAfterUpdateRequest(), app.onSettingsAfterUpdateRequest)
|
||||
}
|
||||
|
||||
if app.onFileDownloadRequest != app.OnFileDownloadRequest() || app.OnFileDownloadRequest() == nil {
|
||||
t.Fatalf("Getter app.OnFileDownloadRequest does not match or nil (%v vs %v)", app.OnFileDownloadRequest(), app.onFileDownloadRequest)
|
||||
}
|
||||
|
||||
if app.onAdminsListRequest != app.OnAdminsListRequest() || app.OnAdminsListRequest() == nil {
|
||||
t.Fatalf("Getter app.OnAdminsListRequest does not match or nil (%v vs %v)", app.OnAdminsListRequest(), app.onAdminsListRequest)
|
||||
}
|
||||
|
||||
if app.onAdminViewRequest != app.OnAdminViewRequest() || app.OnAdminViewRequest() == nil {
|
||||
t.Fatalf("Getter app.OnAdminViewRequest does not match or nil (%v vs %v)", app.OnAdminViewRequest(), app.onAdminViewRequest)
|
||||
}
|
||||
|
||||
if app.onAdminBeforeCreateRequest != app.OnAdminBeforeCreateRequest() || app.OnAdminBeforeCreateRequest() == nil {
|
||||
t.Fatalf("Getter app.OnAdminBeforeCreateRequest does not match or nil (%v vs %v)", app.OnAdminBeforeCreateRequest(), app.onAdminBeforeCreateRequest)
|
||||
}
|
||||
|
||||
if app.onAdminAfterCreateRequest != app.OnAdminAfterCreateRequest() || app.OnAdminAfterCreateRequest() == nil {
|
||||
t.Fatalf("Getter app.OnAdminAfterCreateRequest does not match or nil (%v vs %v)", app.OnAdminAfterCreateRequest(), app.onAdminAfterCreateRequest)
|
||||
}
|
||||
|
||||
if app.onAdminBeforeUpdateRequest != app.OnAdminBeforeUpdateRequest() || app.OnAdminBeforeUpdateRequest() == nil {
|
||||
t.Fatalf("Getter app.OnAdminBeforeUpdateRequest does not match or nil (%v vs %v)", app.OnAdminBeforeUpdateRequest(), app.onAdminBeforeUpdateRequest)
|
||||
}
|
||||
|
||||
if app.onAdminAfterUpdateRequest != app.OnAdminAfterUpdateRequest() || app.OnAdminAfterUpdateRequest() == nil {
|
||||
t.Fatalf("Getter app.OnAdminAfterUpdateRequest does not match or nil (%v vs %v)", app.OnAdminAfterUpdateRequest(), app.onAdminAfterUpdateRequest)
|
||||
}
|
||||
|
||||
if app.onAdminBeforeDeleteRequest != app.OnAdminBeforeDeleteRequest() || app.OnAdminBeforeDeleteRequest() == nil {
|
||||
t.Fatalf("Getter app.OnAdminBeforeDeleteRequest does not match or nil (%v vs %v)", app.OnAdminBeforeDeleteRequest(), app.onAdminBeforeDeleteRequest)
|
||||
}
|
||||
|
||||
if app.onAdminAfterDeleteRequest != app.OnAdminAfterDeleteRequest() || app.OnAdminAfterDeleteRequest() == nil {
|
||||
t.Fatalf("Getter app.OnAdminAfterDeleteRequest does not match or nil (%v vs %v)", app.OnAdminAfterDeleteRequest(), app.onAdminAfterDeleteRequest)
|
||||
}
|
||||
|
||||
if app.onAdminAuthRequest != app.OnAdminAuthRequest() || app.OnAdminAuthRequest() == nil {
|
||||
t.Fatalf("Getter app.OnAdminAuthRequest does not match or nil (%v vs %v)", app.OnAdminAuthRequest(), app.onAdminAuthRequest)
|
||||
}
|
||||
|
||||
if app.onUsersListRequest != app.OnUsersListRequest() || app.OnUsersListRequest() == nil {
|
||||
t.Fatalf("Getter app.OnUsersListRequest does not match or nil (%v vs %v)", app.OnUsersListRequest(), app.onUsersListRequest)
|
||||
}
|
||||
|
||||
if app.onUserViewRequest != app.OnUserViewRequest() || app.OnUserViewRequest() == nil {
|
||||
t.Fatalf("Getter app.OnUserViewRequest does not match or nil (%v vs %v)", app.OnUserViewRequest(), app.onUserViewRequest)
|
||||
}
|
||||
|
||||
if app.onUserBeforeCreateRequest != app.OnUserBeforeCreateRequest() || app.OnUserBeforeCreateRequest() == nil {
|
||||
t.Fatalf("Getter app.OnUserBeforeCreateRequest does not match or nil (%v vs %v)", app.OnUserBeforeCreateRequest(), app.onUserBeforeCreateRequest)
|
||||
}
|
||||
|
||||
if app.onUserAfterCreateRequest != app.OnUserAfterCreateRequest() || app.OnUserAfterCreateRequest() == nil {
|
||||
t.Fatalf("Getter app.OnUserAfterCreateRequest does not match or nil (%v vs %v)", app.OnUserAfterCreateRequest(), app.onUserAfterCreateRequest)
|
||||
}
|
||||
|
||||
if app.onUserBeforeUpdateRequest != app.OnUserBeforeUpdateRequest() || app.OnUserBeforeUpdateRequest() == nil {
|
||||
t.Fatalf("Getter app.OnUserBeforeUpdateRequest does not match or nil (%v vs %v)", app.OnUserBeforeUpdateRequest(), app.onUserBeforeUpdateRequest)
|
||||
}
|
||||
|
||||
if app.onUserAfterUpdateRequest != app.OnUserAfterUpdateRequest() || app.OnUserAfterUpdateRequest() == nil {
|
||||
t.Fatalf("Getter app.OnUserAfterUpdateRequest does not match or nil (%v vs %v)", app.OnUserAfterUpdateRequest(), app.onUserAfterUpdateRequest)
|
||||
}
|
||||
|
||||
if app.onUserBeforeDeleteRequest != app.OnUserBeforeDeleteRequest() || app.OnUserBeforeDeleteRequest() == nil {
|
||||
t.Fatalf("Getter app.OnUserBeforeDeleteRequest does not match or nil (%v vs %v)", app.OnUserBeforeDeleteRequest(), app.onUserBeforeDeleteRequest)
|
||||
}
|
||||
|
||||
if app.onUserAfterDeleteRequest != app.OnUserAfterDeleteRequest() || app.OnUserAfterDeleteRequest() == nil {
|
||||
t.Fatalf("Getter app.OnUserAfterDeleteRequest does not match or nil (%v vs %v)", app.OnUserAfterDeleteRequest(), app.onUserAfterDeleteRequest)
|
||||
}
|
||||
|
||||
if app.onUserAuthRequest != app.OnUserAuthRequest() || app.OnUserAuthRequest() == nil {
|
||||
t.Fatalf("Getter app.OnUserAuthRequest does not match or nil (%v vs %v)", app.OnUserAuthRequest(), app.onUserAuthRequest)
|
||||
}
|
||||
|
||||
if app.onUserBeforeOauth2Register != app.OnUserBeforeOauth2Register() || app.OnUserBeforeOauth2Register() == nil {
|
||||
t.Fatalf("Getter app.OnUserBeforeOauth2Register does not match or nil (%v vs %v)", app.OnUserBeforeOauth2Register(), app.onUserBeforeOauth2Register)
|
||||
}
|
||||
|
||||
if app.onUserAfterOauth2Register != app.OnUserAfterOauth2Register() || app.OnUserAfterOauth2Register() == nil {
|
||||
t.Fatalf("Getter app.OnUserAfterOauth2Register does not match or nil (%v vs %v)", app.OnUserAfterOauth2Register(), app.onUserAfterOauth2Register)
|
||||
}
|
||||
|
||||
if app.onRecordsListRequest != app.OnRecordsListRequest() || app.OnRecordsListRequest() == nil {
|
||||
t.Fatalf("Getter app.OnRecordsListRequest does not match or nil (%v vs %v)", app.OnRecordsListRequest(), app.onRecordsListRequest)
|
||||
}
|
||||
|
||||
if app.onRecordViewRequest != app.OnRecordViewRequest() || app.OnRecordViewRequest() == nil {
|
||||
t.Fatalf("Getter app.OnRecordViewRequest does not match or nil (%v vs %v)", app.OnRecordViewRequest(), app.onRecordViewRequest)
|
||||
}
|
||||
|
||||
if app.onRecordBeforeCreateRequest != app.OnRecordBeforeCreateRequest() || app.OnRecordBeforeCreateRequest() == nil {
|
||||
t.Fatalf("Getter app.OnRecordBeforeCreateRequest does not match or nil (%v vs %v)", app.OnRecordBeforeCreateRequest(), app.onRecordBeforeCreateRequest)
|
||||
}
|
||||
|
||||
if app.onRecordAfterCreateRequest != app.OnRecordAfterCreateRequest() || app.OnRecordAfterCreateRequest() == nil {
|
||||
t.Fatalf("Getter app.OnRecordAfterCreateRequest does not match or nil (%v vs %v)", app.OnRecordAfterCreateRequest(), app.onRecordAfterCreateRequest)
|
||||
}
|
||||
|
||||
if app.onRecordBeforeUpdateRequest != app.OnRecordBeforeUpdateRequest() || app.OnRecordBeforeUpdateRequest() == nil {
|
||||
t.Fatalf("Getter app.OnRecordBeforeUpdateRequest does not match or nil (%v vs %v)", app.OnRecordBeforeUpdateRequest(), app.onRecordBeforeUpdateRequest)
|
||||
}
|
||||
|
||||
if app.onRecordAfterUpdateRequest != app.OnRecordAfterUpdateRequest() || app.OnRecordAfterUpdateRequest() == nil {
|
||||
t.Fatalf("Getter app.OnRecordAfterUpdateRequest does not match or nil (%v vs %v)", app.OnRecordAfterUpdateRequest(), app.onRecordAfterUpdateRequest)
|
||||
}
|
||||
|
||||
if app.onRecordBeforeDeleteRequest != app.OnRecordBeforeDeleteRequest() || app.OnRecordBeforeDeleteRequest() == nil {
|
||||
t.Fatalf("Getter app.OnRecordBeforeDeleteRequest does not match or nil (%v vs %v)", app.OnRecordBeforeDeleteRequest(), app.onRecordBeforeDeleteRequest)
|
||||
}
|
||||
|
||||
if app.onRecordAfterDeleteRequest != app.OnRecordAfterDeleteRequest() || app.OnRecordAfterDeleteRequest() == nil {
|
||||
t.Fatalf("Getter app.OnRecordAfterDeleteRequest does not match or nil (%v vs %v)", app.OnRecordAfterDeleteRequest(), app.onRecordAfterDeleteRequest)
|
||||
}
|
||||
|
||||
if app.onCollectionsListRequest != app.OnCollectionsListRequest() || app.OnCollectionsListRequest() == nil {
|
||||
t.Fatalf("Getter app.OnCollectionsListRequest does not match or nil (%v vs %v)", app.OnCollectionsListRequest(), app.onCollectionsListRequest)
|
||||
}
|
||||
|
||||
if app.onCollectionViewRequest != app.OnCollectionViewRequest() || app.OnCollectionViewRequest() == nil {
|
||||
t.Fatalf("Getter app.OnCollectionViewRequest does not match or nil (%v vs %v)", app.OnCollectionViewRequest(), app.onCollectionViewRequest)
|
||||
}
|
||||
|
||||
if app.onCollectionBeforeCreateRequest != app.OnCollectionBeforeCreateRequest() || app.OnCollectionBeforeCreateRequest() == nil {
|
||||
t.Fatalf("Getter app.OnCollectionBeforeCreateRequest does not match or nil (%v vs %v)", app.OnCollectionBeforeCreateRequest(), app.onCollectionBeforeCreateRequest)
|
||||
}
|
||||
|
||||
if app.onCollectionAfterCreateRequest != app.OnCollectionAfterCreateRequest() || app.OnCollectionAfterCreateRequest() == nil {
|
||||
t.Fatalf("Getter app.OnCollectionAfterCreateRequest does not match or nil (%v vs %v)", app.OnCollectionAfterCreateRequest(), app.onCollectionAfterCreateRequest)
|
||||
}
|
||||
|
||||
if app.onCollectionBeforeUpdateRequest != app.OnCollectionBeforeUpdateRequest() || app.OnCollectionBeforeUpdateRequest() == nil {
|
||||
t.Fatalf("Getter app.OnCollectionBeforeUpdateRequest does not match or nil (%v vs %v)", app.OnCollectionBeforeUpdateRequest(), app.onCollectionBeforeUpdateRequest)
|
||||
}
|
||||
|
||||
if app.onCollectionAfterUpdateRequest != app.OnCollectionAfterUpdateRequest() || app.OnCollectionAfterUpdateRequest() == nil {
|
||||
t.Fatalf("Getter app.OnCollectionAfterUpdateRequest does not match or nil (%v vs %v)", app.OnCollectionAfterUpdateRequest(), app.onCollectionAfterUpdateRequest)
|
||||
}
|
||||
|
||||
if app.onCollectionBeforeDeleteRequest != app.OnCollectionBeforeDeleteRequest() || app.OnCollectionBeforeDeleteRequest() == nil {
|
||||
t.Fatalf("Getter app.OnCollectionBeforeDeleteRequest does not match or nil (%v vs %v)", app.OnCollectionBeforeDeleteRequest(), app.onCollectionBeforeDeleteRequest)
|
||||
}
|
||||
|
||||
if app.onCollectionAfterDeleteRequest != app.OnCollectionAfterDeleteRequest() || app.OnCollectionAfterDeleteRequest() == nil {
|
||||
t.Fatalf("Getter app.OnCollectionAfterDeleteRequest does not match or nil (%v vs %v)", app.OnCollectionAfterDeleteRequest(), app.onCollectionAfterDeleteRequest)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseAppNewMailClient(t *testing.T) {
|
||||
const testDataDir = "./pb_base_app_test_data_dir/"
|
||||
defer os.RemoveAll(testDataDir)
|
||||
|
||||
app := NewBaseApp(testDataDir, "pb_test_env", false)
|
||||
app := NewBaseApp(&BaseAppConfig{
|
||||
DataDir: testDataDir,
|
||||
EncryptionEnv: "pb_test_env",
|
||||
IsDebug: false,
|
||||
})
|
||||
|
||||
client1 := app.NewMailClient()
|
||||
if val, ok := client1.(*mailer.Sendmail); !ok {
|
||||
@@ -415,7 +211,11 @@ func TestBaseAppNewFilesystem(t *testing.T) {
|
||||
const testDataDir = "./pb_base_app_test_data_dir/"
|
||||
defer os.RemoveAll(testDataDir)
|
||||
|
||||
app := NewBaseApp(testDataDir, "pb_test_env", false)
|
||||
app := NewBaseApp(&BaseAppConfig{
|
||||
DataDir: testDataDir,
|
||||
EncryptionEnv: "pb_test_env",
|
||||
IsDebug: false,
|
||||
})
|
||||
|
||||
// local
|
||||
local, localErr := app.NewFilesystem()
|
||||
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"github.com/pocketbase/dbx"
|
||||
)
|
||||
|
||||
func initPragmas(db *dbx.DB) error {
|
||||
// note: the busy_timeout pragma must be first because
|
||||
// the connection needs to be set to block on busy before WAL mode
|
||||
// is set in case it hasn't been already set by another connection
|
||||
_, err := db.NewQuery(`
|
||||
PRAGMA busy_timeout = 10000;
|
||||
PRAGMA journal_mode = WAL;
|
||||
PRAGMA journal_size_limit = 200000000;
|
||||
PRAGMA synchronous = NORMAL;
|
||||
PRAGMA foreign_keys = TRUE;
|
||||
`).Execute()
|
||||
|
||||
return err
|
||||
}
|
||||
+9
-13
@@ -3,24 +3,20 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
"github.com/pocketbase/dbx"
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
func connectDB(dbPath string) (*dbx.DB, error) {
|
||||
pragmas := "_foreign_keys=1&_journal_mode=WAL&_synchronous=NORMAL&_busy_timeout=8000"
|
||||
|
||||
db, openErr := dbx.MustOpen("sqlite3", fmt.Sprintf("%s?%s", dbPath, pragmas))
|
||||
if openErr != nil {
|
||||
return nil, openErr
|
||||
db, err := dbx.Open("sqlite3", dbPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// additional pragmas not supported through the dsn string
|
||||
_, err := db.NewQuery(`
|
||||
pragma journal_size_limit = 100000000;
|
||||
`).Execute()
|
||||
if err := initPragmas(db); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return db, err
|
||||
return db, nil
|
||||
}
|
||||
|
||||
+10
-4
@@ -3,14 +3,20 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
func connectDB(dbPath string) (*dbx.DB, error) {
|
||||
pragmas := "_pragma=foreign_keys(1)&_pragma=journal_mode(WAL)&_pragma=synchronous(NORMAL)&_pragma=busy_timeout(8000)&_pragma=journal_size_limit(100000000)"
|
||||
db, err := dbx.Open("sqlite", dbPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return dbx.MustOpen("sqlite", fmt.Sprintf("%s?%s", dbPath, pragmas))
|
||||
if err := initPragmas(db); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return db, nil
|
||||
}
|
||||
|
||||
+193
-43
@@ -4,7 +4,9 @@ import (
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/models/schema"
|
||||
"github.com/pocketbase/pocketbase/models/settings"
|
||||
"github.com/pocketbase/pocketbase/tools/auth"
|
||||
"github.com/pocketbase/pocketbase/tools/hook"
|
||||
"github.com/pocketbase/pocketbase/tools/mailer"
|
||||
"github.com/pocketbase/pocketbase/tools/search"
|
||||
"github.com/pocketbase/pocketbase/tools/subscriptions"
|
||||
@@ -12,36 +14,85 @@ import (
|
||||
"github.com/labstack/echo/v5"
|
||||
)
|
||||
|
||||
type BaseCollectionEvent struct {
|
||||
Collection *models.Collection
|
||||
}
|
||||
|
||||
func (e *BaseCollectionEvent) Tags() []string {
|
||||
if e.Collection == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
tags := make([]string, 0, 2)
|
||||
|
||||
if e.Collection.Id != "" {
|
||||
tags = append(tags, e.Collection.Id)
|
||||
}
|
||||
|
||||
if e.Collection.Name != "" {
|
||||
tags = append(tags, e.Collection.Name)
|
||||
}
|
||||
|
||||
return tags
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Serve events data
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
type BootstrapEvent struct {
|
||||
App App
|
||||
}
|
||||
|
||||
type ServeEvent struct {
|
||||
App App
|
||||
Router *echo.Echo
|
||||
}
|
||||
|
||||
type ApiErrorEvent struct {
|
||||
HttpContext echo.Context
|
||||
Error error
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Model DAO events data
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
var _ hook.Tagger = (*ModelEvent)(nil)
|
||||
|
||||
type ModelEvent struct {
|
||||
Dao *daos.Dao
|
||||
Model models.Model
|
||||
}
|
||||
|
||||
func (e *ModelEvent) Tags() []string {
|
||||
if e.Model == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if r, ok := e.Model.(*models.Record); ok && r.Collection() != nil {
|
||||
return []string{r.Collection().Id, r.Collection().Name}
|
||||
}
|
||||
|
||||
return []string{e.Model.TableName()}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Mailer events data
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
type MailerUserEvent struct {
|
||||
type MailerRecordEvent struct {
|
||||
BaseCollectionEvent
|
||||
|
||||
MailClient mailer.Mailer
|
||||
User *models.User
|
||||
Message *mailer.Message
|
||||
Record *models.Record
|
||||
Meta map[string]any
|
||||
}
|
||||
|
||||
type MailerAdminEvent struct {
|
||||
MailClient mailer.Mailer
|
||||
Message *mailer.Message
|
||||
Admin *models.Admin
|
||||
Meta map[string]any
|
||||
}
|
||||
@@ -55,6 +106,17 @@ type RealtimeConnectEvent struct {
|
||||
Client subscriptions.Client
|
||||
}
|
||||
|
||||
type RealtimeDisconnectEvent struct {
|
||||
HttpContext echo.Context
|
||||
Client subscriptions.Client
|
||||
}
|
||||
|
||||
type RealtimeMessageEvent struct {
|
||||
HttpContext echo.Context
|
||||
Client subscriptions.Client
|
||||
Message *subscriptions.Message
|
||||
}
|
||||
|
||||
type RealtimeSubscribeEvent struct {
|
||||
HttpContext echo.Context
|
||||
Client subscriptions.Client
|
||||
@@ -67,46 +129,150 @@ type RealtimeSubscribeEvent struct {
|
||||
|
||||
type SettingsListEvent struct {
|
||||
HttpContext echo.Context
|
||||
RedactedSettings *Settings
|
||||
RedactedSettings *settings.Settings
|
||||
}
|
||||
|
||||
type SettingsUpdateEvent struct {
|
||||
HttpContext echo.Context
|
||||
OldSettings *Settings
|
||||
NewSettings *Settings
|
||||
OldSettings *settings.Settings
|
||||
NewSettings *settings.Settings
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Record API events data
|
||||
// Record CRUD API events data
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
type RecordsListEvent struct {
|
||||
BaseCollectionEvent
|
||||
|
||||
HttpContext echo.Context
|
||||
Collection *models.Collection
|
||||
Records []*models.Record
|
||||
Result *search.Result
|
||||
}
|
||||
|
||||
type RecordViewEvent struct {
|
||||
BaseCollectionEvent
|
||||
|
||||
HttpContext echo.Context
|
||||
Record *models.Record
|
||||
}
|
||||
|
||||
type RecordCreateEvent struct {
|
||||
BaseCollectionEvent
|
||||
|
||||
HttpContext echo.Context
|
||||
Record *models.Record
|
||||
}
|
||||
|
||||
type RecordUpdateEvent struct {
|
||||
BaseCollectionEvent
|
||||
|
||||
HttpContext echo.Context
|
||||
Record *models.Record
|
||||
}
|
||||
|
||||
type RecordDeleteEvent struct {
|
||||
BaseCollectionEvent
|
||||
|
||||
HttpContext echo.Context
|
||||
Record *models.Record
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Auth Record API events data
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
type RecordAuthEvent struct {
|
||||
BaseCollectionEvent
|
||||
|
||||
HttpContext echo.Context
|
||||
Record *models.Record
|
||||
Token string
|
||||
Meta any
|
||||
}
|
||||
|
||||
type RecordAuthWithPasswordEvent struct {
|
||||
BaseCollectionEvent
|
||||
|
||||
HttpContext echo.Context
|
||||
Record *models.Record
|
||||
Identity string
|
||||
Password string
|
||||
}
|
||||
|
||||
type RecordAuthWithOAuth2Event struct {
|
||||
BaseCollectionEvent
|
||||
|
||||
HttpContext echo.Context
|
||||
Record *models.Record
|
||||
OAuth2User *auth.AuthUser
|
||||
}
|
||||
|
||||
type RecordAuthRefreshEvent struct {
|
||||
BaseCollectionEvent
|
||||
|
||||
HttpContext echo.Context
|
||||
Record *models.Record
|
||||
}
|
||||
|
||||
type RecordRequestPasswordResetEvent struct {
|
||||
BaseCollectionEvent
|
||||
|
||||
HttpContext echo.Context
|
||||
Record *models.Record
|
||||
}
|
||||
|
||||
type RecordConfirmPasswordResetEvent struct {
|
||||
BaseCollectionEvent
|
||||
|
||||
HttpContext echo.Context
|
||||
Record *models.Record
|
||||
}
|
||||
|
||||
type RecordRequestVerificationEvent struct {
|
||||
BaseCollectionEvent
|
||||
|
||||
HttpContext echo.Context
|
||||
Record *models.Record
|
||||
}
|
||||
|
||||
type RecordConfirmVerificationEvent struct {
|
||||
BaseCollectionEvent
|
||||
|
||||
HttpContext echo.Context
|
||||
Record *models.Record
|
||||
}
|
||||
|
||||
type RecordRequestEmailChangeEvent struct {
|
||||
BaseCollectionEvent
|
||||
|
||||
HttpContext echo.Context
|
||||
Record *models.Record
|
||||
}
|
||||
|
||||
type RecordConfirmEmailChangeEvent struct {
|
||||
BaseCollectionEvent
|
||||
|
||||
HttpContext echo.Context
|
||||
Record *models.Record
|
||||
}
|
||||
|
||||
type RecordListExternalAuthsEvent struct {
|
||||
BaseCollectionEvent
|
||||
|
||||
HttpContext echo.Context
|
||||
Record *models.Record
|
||||
ExternalAuths []*models.ExternalAuth
|
||||
}
|
||||
|
||||
type RecordUnlinkExternalAuthEvent struct {
|
||||
BaseCollectionEvent
|
||||
|
||||
HttpContext echo.Context
|
||||
Record *models.Record
|
||||
ExternalAuth *models.ExternalAuth
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Admin API events data
|
||||
// -------------------------------------------------------------------
|
||||
@@ -143,47 +309,26 @@ type AdminAuthEvent struct {
|
||||
Token string
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// User API events data
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
type UsersListEvent struct {
|
||||
type AdminAuthWithPasswordEvent struct {
|
||||
HttpContext echo.Context
|
||||
Users []*models.User
|
||||
Result *search.Result
|
||||
Admin *models.Admin
|
||||
Identity string
|
||||
Password string
|
||||
}
|
||||
|
||||
type UserViewEvent struct {
|
||||
type AdminAuthRefreshEvent struct {
|
||||
HttpContext echo.Context
|
||||
User *models.User
|
||||
Admin *models.Admin
|
||||
}
|
||||
|
||||
type UserCreateEvent struct {
|
||||
type AdminRequestPasswordResetEvent struct {
|
||||
HttpContext echo.Context
|
||||
User *models.User
|
||||
Admin *models.Admin
|
||||
}
|
||||
|
||||
type UserUpdateEvent struct {
|
||||
type AdminConfirmPasswordResetEvent struct {
|
||||
HttpContext echo.Context
|
||||
User *models.User
|
||||
}
|
||||
|
||||
type UserDeleteEvent struct {
|
||||
HttpContext echo.Context
|
||||
User *models.User
|
||||
}
|
||||
|
||||
type UserAuthEvent struct {
|
||||
HttpContext echo.Context
|
||||
User *models.User
|
||||
Token string
|
||||
Meta any
|
||||
}
|
||||
|
||||
type UserOauth2RegisterEvent struct {
|
||||
HttpContext echo.Context
|
||||
User *models.User
|
||||
AuthData *auth.AuthUser
|
||||
Admin *models.Admin
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
@@ -197,23 +342,27 @@ type CollectionsListEvent struct {
|
||||
}
|
||||
|
||||
type CollectionViewEvent struct {
|
||||
BaseCollectionEvent
|
||||
|
||||
HttpContext echo.Context
|
||||
Collection *models.Collection
|
||||
}
|
||||
|
||||
type CollectionCreateEvent struct {
|
||||
BaseCollectionEvent
|
||||
|
||||
HttpContext echo.Context
|
||||
Collection *models.Collection
|
||||
}
|
||||
|
||||
type CollectionUpdateEvent struct {
|
||||
BaseCollectionEvent
|
||||
|
||||
HttpContext echo.Context
|
||||
Collection *models.Collection
|
||||
}
|
||||
|
||||
type CollectionDeleteEvent struct {
|
||||
BaseCollectionEvent
|
||||
|
||||
HttpContext echo.Context
|
||||
Collection *models.Collection
|
||||
}
|
||||
|
||||
type CollectionsImportEvent struct {
|
||||
@@ -226,8 +375,9 @@ type CollectionsImportEvent struct {
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
type FileDownloadEvent struct {
|
||||
BaseCollectionEvent
|
||||
|
||||
HttpContext echo.Context
|
||||
Collection *models.Collection
|
||||
Record *models.Record
|
||||
FileField *schema.SchemaField
|
||||
ServedPath string
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package core_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/tools/list"
|
||||
)
|
||||
|
||||
func TestBaseCollectionEventTags(t *testing.T) {
|
||||
c1 := new(models.Collection)
|
||||
|
||||
c2 := new(models.Collection)
|
||||
c2.Id = "a"
|
||||
|
||||
c3 := new(models.Collection)
|
||||
c3.Name = "b"
|
||||
|
||||
c4 := new(models.Collection)
|
||||
c4.Id = "a"
|
||||
c4.Name = "b"
|
||||
|
||||
scenarios := []struct {
|
||||
collection *models.Collection
|
||||
expectedTags []string
|
||||
}{
|
||||
{c1, []string{}},
|
||||
{c2, []string{"a"}},
|
||||
{c3, []string{"b"}},
|
||||
{c4, []string{"a", "b"}},
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
event := new(core.BaseCollectionEvent)
|
||||
event.Collection = s.collection
|
||||
|
||||
tags := event.Tags()
|
||||
|
||||
if len(s.expectedTags) != len(tags) {
|
||||
t.Fatalf("[%d] Expected %v tags, got %v", i, s.expectedTags, tags)
|
||||
}
|
||||
|
||||
for _, tag := range s.expectedTags {
|
||||
if !list.ExistInSlice(tag, tags) {
|
||||
t.Fatalf("[%d] Expected %v tags, got %v", i, s.expectedTags, tags)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelEventTags(t *testing.T) {
|
||||
m1 := new(models.Admin)
|
||||
|
||||
c := new(models.Collection)
|
||||
c.Id = "a"
|
||||
c.Name = "b"
|
||||
m2 := models.NewRecord(c)
|
||||
|
||||
scenarios := []struct {
|
||||
model models.Model
|
||||
expectedTags []string
|
||||
}{
|
||||
{m1, []string{"_admins"}},
|
||||
{m2, []string{"a", "b"}},
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
event := new(core.ModelEvent)
|
||||
event.Model = s.model
|
||||
|
||||
tags := event.Tags()
|
||||
|
||||
if len(s.expectedTags) != len(tags) {
|
||||
t.Fatalf("[%d] Expected %v tags, got %v", i, s.expectedTags, tags)
|
||||
}
|
||||
|
||||
for _, tag := range s.expectedTags {
|
||||
if !list.ExistInSlice(tag, tags) {
|
||||
t.Fatalf("[%d] Expected %v tags, got %v", i, s.expectedTags, tags)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,413 +0,0 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/go-ozzo/ozzo-validation/v4/is"
|
||||
"github.com/pocketbase/pocketbase/tools/auth"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
)
|
||||
|
||||
// Common settings placeholder tokens
|
||||
const (
|
||||
EmailPlaceholderAppUrl string = "%APP_URL%"
|
||||
EmailPlaceholderToken string = "%TOKEN%"
|
||||
)
|
||||
|
||||
// Settings defines common app configuration options.
|
||||
type Settings struct {
|
||||
mux sync.RWMutex
|
||||
|
||||
Meta MetaConfig `form:"meta" json:"meta"`
|
||||
Logs LogsConfig `form:"logs" json:"logs"`
|
||||
Smtp SmtpConfig `form:"smtp" json:"smtp"`
|
||||
S3 S3Config `form:"s3" json:"s3"`
|
||||
AdminAuthToken TokenConfig `form:"adminAuthToken" json:"adminAuthToken"`
|
||||
AdminPasswordResetToken TokenConfig `form:"adminPasswordResetToken" json:"adminPasswordResetToken"`
|
||||
UserAuthToken TokenConfig `form:"userAuthToken" json:"userAuthToken"`
|
||||
UserPasswordResetToken TokenConfig `form:"userPasswordResetToken" json:"userPasswordResetToken"`
|
||||
UserEmailChangeToken TokenConfig `form:"userEmailChangeToken" json:"userEmailChangeToken"`
|
||||
UserVerificationToken TokenConfig `form:"userVerificationToken" json:"userVerificationToken"`
|
||||
EmailAuth EmailAuthConfig `form:"emailAuth" json:"emailAuth"`
|
||||
GoogleAuth AuthProviderConfig `form:"googleAuth" json:"googleAuth"`
|
||||
FacebookAuth AuthProviderConfig `form:"facebookAuth" json:"facebookAuth"`
|
||||
GithubAuth AuthProviderConfig `form:"githubAuth" json:"githubAuth"`
|
||||
GitlabAuth AuthProviderConfig `form:"gitlabAuth" json:"gitlabAuth"`
|
||||
}
|
||||
|
||||
// NewSettings creates and returns a new default Settings instance.
|
||||
func NewSettings() *Settings {
|
||||
return &Settings{
|
||||
Meta: MetaConfig{
|
||||
AppName: "Acme",
|
||||
AppUrl: "http://localhost:8090",
|
||||
SenderName: "Support",
|
||||
SenderAddress: "support@example.com",
|
||||
UserVerificationUrl: EmailPlaceholderAppUrl + "/_/#/users/confirm-verification/" + EmailPlaceholderToken,
|
||||
UserResetPasswordUrl: EmailPlaceholderAppUrl + "/_/#/users/confirm-password-reset/" + EmailPlaceholderToken,
|
||||
UserConfirmEmailChangeUrl: EmailPlaceholderAppUrl + "/_/#/users/confirm-email-change/" + EmailPlaceholderToken,
|
||||
},
|
||||
Logs: LogsConfig{
|
||||
MaxDays: 7,
|
||||
},
|
||||
Smtp: SmtpConfig{
|
||||
Enabled: false,
|
||||
Host: "smtp.example.com",
|
||||
Port: 587,
|
||||
Username: "",
|
||||
Password: "",
|
||||
Tls: false,
|
||||
},
|
||||
AdminAuthToken: TokenConfig{
|
||||
Secret: security.RandomString(50),
|
||||
Duration: 1209600, // 14 days,
|
||||
},
|
||||
AdminPasswordResetToken: TokenConfig{
|
||||
Secret: security.RandomString(50),
|
||||
Duration: 1800, // 30 minutes,
|
||||
},
|
||||
UserAuthToken: TokenConfig{
|
||||
Secret: security.RandomString(50),
|
||||
Duration: 1209600, // 14 days,
|
||||
},
|
||||
UserPasswordResetToken: TokenConfig{
|
||||
Secret: security.RandomString(50),
|
||||
Duration: 1800, // 30 minutes,
|
||||
},
|
||||
UserVerificationToken: TokenConfig{
|
||||
Secret: security.RandomString(50),
|
||||
Duration: 604800, // 7 days,
|
||||
},
|
||||
UserEmailChangeToken: TokenConfig{
|
||||
Secret: security.RandomString(50),
|
||||
Duration: 1800, // 30 minutes,
|
||||
},
|
||||
EmailAuth: EmailAuthConfig{
|
||||
Enabled: true,
|
||||
MinPasswordLength: 8,
|
||||
},
|
||||
GoogleAuth: AuthProviderConfig{
|
||||
Enabled: false,
|
||||
AllowRegistrations: true,
|
||||
},
|
||||
FacebookAuth: AuthProviderConfig{
|
||||
Enabled: false,
|
||||
AllowRegistrations: true,
|
||||
},
|
||||
GithubAuth: AuthProviderConfig{
|
||||
Enabled: false,
|
||||
AllowRegistrations: true,
|
||||
},
|
||||
GitlabAuth: AuthProviderConfig{
|
||||
Enabled: false,
|
||||
AllowRegistrations: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Validate makes Settings validatable by implementing [validation.Validatable] interface.
|
||||
func (s *Settings) Validate() error {
|
||||
s.mux.Lock()
|
||||
defer s.mux.Unlock()
|
||||
|
||||
return validation.ValidateStruct(s,
|
||||
validation.Field(&s.Meta),
|
||||
validation.Field(&s.Logs),
|
||||
validation.Field(&s.AdminAuthToken),
|
||||
validation.Field(&s.AdminPasswordResetToken),
|
||||
validation.Field(&s.UserAuthToken),
|
||||
validation.Field(&s.UserPasswordResetToken),
|
||||
validation.Field(&s.UserEmailChangeToken),
|
||||
validation.Field(&s.UserVerificationToken),
|
||||
validation.Field(&s.Smtp),
|
||||
validation.Field(&s.S3),
|
||||
validation.Field(&s.EmailAuth),
|
||||
validation.Field(&s.GoogleAuth),
|
||||
validation.Field(&s.FacebookAuth),
|
||||
validation.Field(&s.GithubAuth),
|
||||
validation.Field(&s.GitlabAuth),
|
||||
)
|
||||
}
|
||||
|
||||
// Merge merges `other` settings into the current one.
|
||||
func (s *Settings) Merge(other *Settings) error {
|
||||
s.mux.Lock()
|
||||
defer s.mux.Unlock()
|
||||
|
||||
bytes, err := json.Marshal(other)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return json.Unmarshal(bytes, s)
|
||||
}
|
||||
|
||||
// Clone creates a new deep copy of the current settings.
|
||||
func (s *Settings) Clone() (*Settings, error) {
|
||||
settings := &Settings{}
|
||||
if err := settings.Merge(s); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return settings, nil
|
||||
}
|
||||
|
||||
// RedactClone creates a new deep copy of the current settings,
|
||||
// while replacing the secret values with `******`.
|
||||
func (s *Settings) RedactClone() (*Settings, error) {
|
||||
clone, err := s.Clone()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
mask := "******"
|
||||
|
||||
sensitiveFields := []*string{
|
||||
&clone.Smtp.Password,
|
||||
&clone.S3.Secret,
|
||||
&clone.AdminAuthToken.Secret,
|
||||
&clone.AdminPasswordResetToken.Secret,
|
||||
&clone.UserAuthToken.Secret,
|
||||
&clone.UserPasswordResetToken.Secret,
|
||||
&clone.UserEmailChangeToken.Secret,
|
||||
&clone.UserVerificationToken.Secret,
|
||||
&clone.GoogleAuth.ClientSecret,
|
||||
&clone.FacebookAuth.ClientSecret,
|
||||
&clone.GithubAuth.ClientSecret,
|
||||
&clone.GitlabAuth.ClientSecret,
|
||||
}
|
||||
|
||||
// mask all sensitive fields
|
||||
for _, v := range sensitiveFields {
|
||||
if v != nil && *v != "" {
|
||||
*v = mask
|
||||
}
|
||||
}
|
||||
|
||||
return clone, nil
|
||||
}
|
||||
|
||||
// NamedAuthProviderConfigs returns a map with all registered OAuth2
|
||||
// provider configurations (indexed by their name identifier).
|
||||
func (s *Settings) NamedAuthProviderConfigs() map[string]AuthProviderConfig {
|
||||
return map[string]AuthProviderConfig{
|
||||
auth.NameGoogle: s.GoogleAuth,
|
||||
auth.NameFacebook: s.FacebookAuth,
|
||||
auth.NameGithub: s.GithubAuth,
|
||||
auth.NameGitlab: s.GitlabAuth,
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
type TokenConfig struct {
|
||||
Secret string `form:"secret" json:"secret"`
|
||||
Duration int64 `form:"duration" json:"duration"`
|
||||
}
|
||||
|
||||
// Validate makes TokenConfig validatable by implementing [validation.Validatable] interface.
|
||||
func (c TokenConfig) Validate() error {
|
||||
return validation.ValidateStruct(&c,
|
||||
validation.Field(&c.Secret, validation.Required, validation.Length(30, 300)),
|
||||
validation.Field(&c.Duration, validation.Required, validation.Min(5), validation.Max(31536000)),
|
||||
)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
type SmtpConfig struct {
|
||||
Enabled bool `form:"enabled" json:"enabled"`
|
||||
Host string `form:"host" json:"host"`
|
||||
Port int `form:"port" json:"port"`
|
||||
Username string `form:"username" json:"username"`
|
||||
Password string `form:"password" json:"password"`
|
||||
|
||||
// Whether to enforce TLS encryption for the mail server connection.
|
||||
//
|
||||
// When set to false StartTLS command is send, leaving the server
|
||||
// to decide whether to upgrade the connection or not.
|
||||
Tls bool `form:"tls" json:"tls"`
|
||||
}
|
||||
|
||||
// Validate makes SmtpConfig validatable by implementing [validation.Validatable] interface.
|
||||
func (c SmtpConfig) Validate() error {
|
||||
return validation.ValidateStruct(&c,
|
||||
validation.Field(&c.Host, is.Host, validation.When(c.Enabled, validation.Required)),
|
||||
validation.Field(&c.Port, validation.When(c.Enabled, validation.Required), validation.Min(0)),
|
||||
)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
type S3Config struct {
|
||||
Enabled bool `form:"enabled" json:"enabled"`
|
||||
Bucket string `form:"bucket" json:"bucket"`
|
||||
Region string `form:"region" json:"region"`
|
||||
Endpoint string `form:"endpoint" json:"endpoint"`
|
||||
AccessKey string `form:"accessKey" json:"accessKey"`
|
||||
Secret string `form:"secret" json:"secret"`
|
||||
ForcePathStyle bool `form:"forcePathStyle" json:"forcePathStyle"`
|
||||
}
|
||||
|
||||
// Validate makes S3Config validatable by implementing [validation.Validatable] interface.
|
||||
func (c S3Config) Validate() error {
|
||||
return validation.ValidateStruct(&c,
|
||||
validation.Field(&c.Endpoint, is.URL, validation.When(c.Enabled, validation.Required)),
|
||||
validation.Field(&c.Bucket, validation.When(c.Enabled, validation.Required)),
|
||||
validation.Field(&c.Region, validation.When(c.Enabled, validation.Required)),
|
||||
validation.Field(&c.AccessKey, validation.When(c.Enabled, validation.Required)),
|
||||
validation.Field(&c.Secret, validation.When(c.Enabled, validation.Required)),
|
||||
)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
type MetaConfig struct {
|
||||
AppName string `form:"appName" json:"appName"`
|
||||
AppUrl string `form:"appUrl" json:"appUrl"`
|
||||
SenderName string `form:"senderName" json:"senderName"`
|
||||
SenderAddress string `form:"senderAddress" json:"senderAddress"`
|
||||
UserVerificationUrl string `form:"userVerificationUrl" json:"userVerificationUrl"`
|
||||
UserResetPasswordUrl string `form:"userResetPasswordUrl" json:"userResetPasswordUrl"`
|
||||
UserConfirmEmailChangeUrl string `form:"userConfirmEmailChangeUrl" json:"userConfirmEmailChangeUrl"`
|
||||
}
|
||||
|
||||
// Validate makes MetaConfig validatable by implementing [validation.Validatable] interface.
|
||||
func (c MetaConfig) Validate() error {
|
||||
return validation.ValidateStruct(&c,
|
||||
validation.Field(&c.AppName, validation.Required, validation.Length(1, 255)),
|
||||
validation.Field(&c.AppUrl, validation.Required, is.URL),
|
||||
validation.Field(&c.SenderName, validation.Required, validation.Length(1, 255)),
|
||||
validation.Field(&c.SenderAddress, is.Email, validation.Required),
|
||||
validation.Field(
|
||||
&c.UserVerificationUrl,
|
||||
validation.Required,
|
||||
validation.By(c.checkPlaceholders(EmailPlaceholderToken)),
|
||||
),
|
||||
validation.Field(
|
||||
&c.UserResetPasswordUrl,
|
||||
validation.Required,
|
||||
validation.By(c.checkPlaceholders(EmailPlaceholderToken)),
|
||||
),
|
||||
validation.Field(
|
||||
&c.UserConfirmEmailChangeUrl,
|
||||
validation.Required,
|
||||
validation.By(c.checkPlaceholders(EmailPlaceholderToken)),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func (c *MetaConfig) checkPlaceholders(params ...string) validation.RuleFunc {
|
||||
return func(value any) error {
|
||||
v, _ := value.(string)
|
||||
if v == "" {
|
||||
return nil // nothing to check
|
||||
}
|
||||
|
||||
for _, param := range params {
|
||||
if !strings.Contains(v, param) {
|
||||
return validation.NewError("validation_missing_required_param", fmt.Sprintf("Missing required parameter %q", param))
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
type LogsConfig struct {
|
||||
MaxDays int `form:"maxDays" json:"maxDays"`
|
||||
}
|
||||
|
||||
// Validate makes LogsConfig validatable by implementing [validation.Validatable] interface.
|
||||
func (c LogsConfig) Validate() error {
|
||||
return validation.ValidateStruct(&c,
|
||||
validation.Field(&c.MaxDays, validation.Min(0)),
|
||||
)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
type AuthProviderConfig struct {
|
||||
Enabled bool `form:"enabled" json:"enabled"`
|
||||
AllowRegistrations bool `form:"allowRegistrations" json:"allowRegistrations"`
|
||||
ClientId string `form:"clientId" json:"clientId,omitempty"`
|
||||
ClientSecret string `form:"clientSecret" json:"clientSecret,omitempty"`
|
||||
AuthUrl string `form:"authUrl" json:"authUrl,omitempty"`
|
||||
TokenUrl string `form:"tokenUrl" json:"tokenUrl,omitempty"`
|
||||
UserApiUrl string `form:"userApiUrl" json:"userApiUrl,omitempty"`
|
||||
}
|
||||
|
||||
// Validate makes `ProviderConfig` validatable by implementing [validation.Validatable] interface.
|
||||
func (c AuthProviderConfig) Validate() error {
|
||||
return validation.ValidateStruct(&c,
|
||||
validation.Field(&c.ClientId, validation.When(c.Enabled, validation.Required)),
|
||||
validation.Field(&c.ClientSecret, validation.When(c.Enabled, validation.Required)),
|
||||
validation.Field(&c.AuthUrl, is.URL),
|
||||
validation.Field(&c.TokenUrl, is.URL),
|
||||
validation.Field(&c.UserApiUrl, is.URL),
|
||||
)
|
||||
}
|
||||
|
||||
// SetupProvider loads the current AuthProviderConfig into the specified provider.
|
||||
func (c AuthProviderConfig) SetupProvider(provider auth.Provider) error {
|
||||
if !c.Enabled {
|
||||
return errors.New("The provider is not enabled.")
|
||||
}
|
||||
|
||||
if c.ClientId != "" {
|
||||
provider.SetClientId(c.ClientId)
|
||||
}
|
||||
|
||||
if c.ClientSecret != "" {
|
||||
provider.SetClientSecret(c.ClientSecret)
|
||||
}
|
||||
|
||||
if c.AuthUrl != "" {
|
||||
provider.SetAuthUrl(c.AuthUrl)
|
||||
}
|
||||
|
||||
if c.UserApiUrl != "" {
|
||||
provider.SetUserApiUrl(c.UserApiUrl)
|
||||
}
|
||||
|
||||
if c.TokenUrl != "" {
|
||||
provider.SetTokenUrl(c.TokenUrl)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
type EmailAuthConfig struct {
|
||||
Enabled bool `form:"enabled" json:"enabled"`
|
||||
ExceptDomains []string `form:"exceptDomains" json:"exceptDomains"`
|
||||
OnlyDomains []string `form:"onlyDomains" json:"onlyDomains"`
|
||||
MinPasswordLength int `form:"minPasswordLength" json:"minPasswordLength"`
|
||||
}
|
||||
|
||||
// Validate makes `EmailAuthConfig` validatable by implementing [validation.Validatable] interface.
|
||||
func (c EmailAuthConfig) Validate() error {
|
||||
return validation.ValidateStruct(&c,
|
||||
validation.Field(
|
||||
&c.ExceptDomains,
|
||||
validation.When(len(c.OnlyDomains) > 0, validation.Empty).Else(validation.Each(is.Domain)),
|
||||
),
|
||||
validation.Field(
|
||||
&c.OnlyDomains,
|
||||
validation.When(len(c.ExceptDomains) > 0, validation.Empty).Else(validation.Each(is.Domain)),
|
||||
),
|
||||
validation.Field(
|
||||
&c.MinPasswordLength,
|
||||
validation.When(c.Enabled, validation.Required),
|
||||
validation.Min(5),
|
||||
validation.Max(100),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -1,618 +0,0 @@
|
||||
package core_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tools/auth"
|
||||
)
|
||||
|
||||
func TestSettingsValidate(t *testing.T) {
|
||||
s := core.NewSettings()
|
||||
|
||||
// set invalid settings data
|
||||
s.Meta.AppName = ""
|
||||
s.Logs.MaxDays = -10
|
||||
s.Smtp.Enabled = true
|
||||
s.Smtp.Host = ""
|
||||
s.S3.Enabled = true
|
||||
s.S3.Endpoint = "invalid"
|
||||
s.AdminAuthToken.Duration = -10
|
||||
s.AdminPasswordResetToken.Duration = -10
|
||||
s.UserAuthToken.Duration = -10
|
||||
s.UserPasswordResetToken.Duration = -10
|
||||
s.UserEmailChangeToken.Duration = -10
|
||||
s.UserVerificationToken.Duration = -10
|
||||
s.EmailAuth.Enabled = true
|
||||
s.EmailAuth.MinPasswordLength = -10
|
||||
s.GoogleAuth.Enabled = true
|
||||
s.GoogleAuth.ClientId = ""
|
||||
s.FacebookAuth.Enabled = true
|
||||
s.FacebookAuth.ClientId = ""
|
||||
s.GithubAuth.Enabled = true
|
||||
s.GithubAuth.ClientId = ""
|
||||
s.GitlabAuth.Enabled = true
|
||||
s.GitlabAuth.ClientId = ""
|
||||
|
||||
// check if Validate() is triggering the members validate methods.
|
||||
err := s.Validate()
|
||||
if err == nil {
|
||||
t.Fatalf("Expected error, got nil")
|
||||
}
|
||||
|
||||
expectations := []string{
|
||||
`"meta":{`,
|
||||
`"logs":{`,
|
||||
`"smtp":{`,
|
||||
`"s3":{`,
|
||||
`"adminAuthToken":{`,
|
||||
`"adminPasswordResetToken":{`,
|
||||
`"userAuthToken":{`,
|
||||
`"userPasswordResetToken":{`,
|
||||
`"userEmailChangeToken":{`,
|
||||
`"userVerificationToken":{`,
|
||||
`"emailAuth":{`,
|
||||
`"googleAuth":{`,
|
||||
`"facebookAuth":{`,
|
||||
`"githubAuth":{`,
|
||||
`"gitlabAuth":{`,
|
||||
}
|
||||
|
||||
errBytes, _ := json.Marshal(err)
|
||||
jsonErr := string(errBytes)
|
||||
for _, expected := range expectations {
|
||||
if !strings.Contains(jsonErr, expected) {
|
||||
t.Errorf("Expected error key %s in %v", expected, jsonErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettingsMerge(t *testing.T) {
|
||||
s1 := core.NewSettings()
|
||||
s1.Meta.AppUrl = "old_app_url"
|
||||
|
||||
s2 := core.NewSettings()
|
||||
s2.Meta.AppName = "test"
|
||||
s2.Logs.MaxDays = 123
|
||||
s2.Smtp.Host = "test"
|
||||
s2.Smtp.Enabled = true
|
||||
s2.S3.Enabled = true
|
||||
s2.S3.Endpoint = "test"
|
||||
s2.AdminAuthToken.Duration = 1
|
||||
s2.AdminPasswordResetToken.Duration = 2
|
||||
s2.UserAuthToken.Duration = 3
|
||||
s2.UserPasswordResetToken.Duration = 4
|
||||
s2.UserEmailChangeToken.Duration = 5
|
||||
s2.UserVerificationToken.Duration = 6
|
||||
s2.EmailAuth.Enabled = false
|
||||
s2.EmailAuth.MinPasswordLength = 30
|
||||
s2.GoogleAuth.Enabled = true
|
||||
s2.GoogleAuth.ClientId = "google_test"
|
||||
s2.FacebookAuth.Enabled = true
|
||||
s2.FacebookAuth.ClientId = "facebook_test"
|
||||
s2.GithubAuth.Enabled = true
|
||||
s2.GithubAuth.ClientId = "github_test"
|
||||
s2.GitlabAuth.Enabled = true
|
||||
s2.GitlabAuth.ClientId = "gitlab_test"
|
||||
|
||||
if err := s1.Merge(s2); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
s1Encoded, err := json.Marshal(s1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
s2Encoded, err := json.Marshal(s2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if string(s1Encoded) != string(s2Encoded) {
|
||||
t.Fatalf("Expected the same serialization, got %v VS %v", string(s1Encoded), string(s2Encoded))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettingsClone(t *testing.T) {
|
||||
s1 := core.NewSettings()
|
||||
|
||||
s2, err := s1.Clone()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
s1Bytes, err := json.Marshal(s1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
s2Bytes, err := json.Marshal(s2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if string(s1Bytes) != string(s2Bytes) {
|
||||
t.Fatalf("Expected equivalent serialization, got %v VS %v", string(s1Bytes), string(s2Bytes))
|
||||
}
|
||||
|
||||
// verify that it is a deep copy
|
||||
s1.Meta.AppName = "new"
|
||||
if s1.Meta.AppName == s2.Meta.AppName {
|
||||
t.Fatalf("Expected s1 and s2 to have different Meta.AppName, got %s", s1.Meta.AppName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettingsRedactClone(t *testing.T) {
|
||||
s1 := core.NewSettings()
|
||||
s1.Meta.AppName = "test123" // control field
|
||||
s1.Smtp.Password = "test123"
|
||||
s1.Smtp.Tls = true
|
||||
s1.S3.Secret = "test123"
|
||||
s1.AdminAuthToken.Secret = "test123"
|
||||
s1.AdminPasswordResetToken.Secret = "test123"
|
||||
s1.UserAuthToken.Secret = "test123"
|
||||
s1.UserPasswordResetToken.Secret = "test123"
|
||||
s1.UserEmailChangeToken.Secret = "test123"
|
||||
s1.UserVerificationToken.Secret = "test123"
|
||||
s1.GoogleAuth.ClientSecret = "test123"
|
||||
s1.FacebookAuth.ClientSecret = "test123"
|
||||
s1.GithubAuth.ClientSecret = "test123"
|
||||
s1.GitlabAuth.ClientSecret = "test123"
|
||||
|
||||
s2, err := s1.RedactClone()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
encoded, err := json.Marshal(s2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
expected := `{"meta":{"appName":"test123","appUrl":"http://localhost:8090","senderName":"Support","senderAddress":"support@example.com","userVerificationUrl":"%APP_URL%/_/#/users/confirm-verification/%TOKEN%","userResetPasswordUrl":"%APP_URL%/_/#/users/confirm-password-reset/%TOKEN%","userConfirmEmailChangeUrl":"%APP_URL%/_/#/users/confirm-email-change/%TOKEN%"},"logs":{"maxDays":7},"smtp":{"enabled":false,"host":"smtp.example.com","port":587,"username":"","password":"******","tls":true},"s3":{"enabled":false,"bucket":"","region":"","endpoint":"","accessKey":"","secret":"******","forcePathStyle":false},"adminAuthToken":{"secret":"******","duration":1209600},"adminPasswordResetToken":{"secret":"******","duration":1800},"userAuthToken":{"secret":"******","duration":1209600},"userPasswordResetToken":{"secret":"******","duration":1800},"userEmailChangeToken":{"secret":"******","duration":1800},"userVerificationToken":{"secret":"******","duration":604800},"emailAuth":{"enabled":true,"exceptDomains":null,"onlyDomains":null,"minPasswordLength":8},"googleAuth":{"enabled":false,"allowRegistrations":true,"clientSecret":"******"},"facebookAuth":{"enabled":false,"allowRegistrations":true,"clientSecret":"******"},"githubAuth":{"enabled":false,"allowRegistrations":true,"clientSecret":"******"},"gitlabAuth":{"enabled":false,"allowRegistrations":true,"clientSecret":"******"}}`
|
||||
|
||||
if encodedStr := string(encoded); encodedStr != expected {
|
||||
t.Fatalf("Expected %v, got \n%v", expected, encodedStr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNamedAuthProviderConfigs(t *testing.T) {
|
||||
s := core.NewSettings()
|
||||
|
||||
s.GoogleAuth.ClientId = "google_test"
|
||||
s.FacebookAuth.ClientId = "facebook_test"
|
||||
s.GithubAuth.ClientId = "github_test"
|
||||
s.GitlabAuth.ClientId = "gitlab_test"
|
||||
s.GitlabAuth.Enabled = true
|
||||
|
||||
result := s.NamedAuthProviderConfigs()
|
||||
|
||||
encoded, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
expected := `{"facebook":{"enabled":false,"allowRegistrations":true,"clientId":"facebook_test"},"github":{"enabled":false,"allowRegistrations":true,"clientId":"github_test"},"gitlab":{"enabled":true,"allowRegistrations":true,"clientId":"gitlab_test"},"google":{"enabled":false,"allowRegistrations":true,"clientId":"google_test"}}`
|
||||
|
||||
if encodedStr := string(encoded); encodedStr != expected {
|
||||
t.Fatalf("Expected the same serialization, got %v", encodedStr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenConfigValidate(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
config core.TokenConfig
|
||||
expectError bool
|
||||
}{
|
||||
// zero values
|
||||
{
|
||||
core.TokenConfig{},
|
||||
true,
|
||||
},
|
||||
// invalid data
|
||||
{
|
||||
core.TokenConfig{
|
||||
Secret: "test",
|
||||
Duration: 4,
|
||||
},
|
||||
true,
|
||||
},
|
||||
// valid data
|
||||
{
|
||||
core.TokenConfig{
|
||||
Secret: "testtesttesttesttesttesttestte",
|
||||
Duration: 100,
|
||||
},
|
||||
false,
|
||||
},
|
||||
}
|
||||
|
||||
for i, scenario := range scenarios {
|
||||
result := scenario.config.Validate()
|
||||
|
||||
if result != nil && !scenario.expectError {
|
||||
t.Errorf("(%d) Didn't expect error, got %v", i, result)
|
||||
}
|
||||
|
||||
if result == nil && scenario.expectError {
|
||||
t.Errorf("(%d) Expected error, got nil", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSmtpConfigValidate(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
config core.SmtpConfig
|
||||
expectError bool
|
||||
}{
|
||||
// zero values (disabled)
|
||||
{
|
||||
core.SmtpConfig{},
|
||||
false,
|
||||
},
|
||||
// zero values (enabled)
|
||||
{
|
||||
core.SmtpConfig{Enabled: true},
|
||||
true,
|
||||
},
|
||||
// invalid data
|
||||
{
|
||||
core.SmtpConfig{
|
||||
Enabled: true,
|
||||
Host: "test:test:test",
|
||||
Port: -10,
|
||||
},
|
||||
true,
|
||||
},
|
||||
// valid data
|
||||
{
|
||||
core.SmtpConfig{
|
||||
Enabled: true,
|
||||
Host: "example.com",
|
||||
Port: 100,
|
||||
Tls: true,
|
||||
},
|
||||
false,
|
||||
},
|
||||
}
|
||||
|
||||
for i, scenario := range scenarios {
|
||||
result := scenario.config.Validate()
|
||||
|
||||
if result != nil && !scenario.expectError {
|
||||
t.Errorf("(%d) Didn't expect error, got %v", i, result)
|
||||
}
|
||||
|
||||
if result == nil && scenario.expectError {
|
||||
t.Errorf("(%d) Expected error, got nil", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestS3ConfigValidate(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
config core.S3Config
|
||||
expectError bool
|
||||
}{
|
||||
// zero values (disabled)
|
||||
{
|
||||
core.S3Config{},
|
||||
false,
|
||||
},
|
||||
// zero values (enabled)
|
||||
{
|
||||
core.S3Config{Enabled: true},
|
||||
true,
|
||||
},
|
||||
// invalid data
|
||||
{
|
||||
core.S3Config{
|
||||
Enabled: true,
|
||||
Endpoint: "test:test:test",
|
||||
},
|
||||
true,
|
||||
},
|
||||
// valid data (url endpoint)
|
||||
{
|
||||
core.S3Config{
|
||||
Enabled: true,
|
||||
Endpoint: "https://localhost:8090",
|
||||
Bucket: "test",
|
||||
Region: "test",
|
||||
AccessKey: "test",
|
||||
Secret: "test",
|
||||
},
|
||||
false,
|
||||
},
|
||||
// valid data (hostname endpoint)
|
||||
{
|
||||
core.S3Config{
|
||||
Enabled: true,
|
||||
Endpoint: "example.com",
|
||||
Bucket: "test",
|
||||
Region: "test",
|
||||
AccessKey: "test",
|
||||
Secret: "test",
|
||||
},
|
||||
false,
|
||||
},
|
||||
}
|
||||
|
||||
for i, scenario := range scenarios {
|
||||
result := scenario.config.Validate()
|
||||
|
||||
if result != nil && !scenario.expectError {
|
||||
t.Errorf("(%d) Didn't expect error, got %v", i, result)
|
||||
}
|
||||
|
||||
if result == nil && scenario.expectError {
|
||||
t.Errorf("(%d) Expected error, got nil", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetaConfigValidate(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
config core.MetaConfig
|
||||
expectError bool
|
||||
}{
|
||||
// zero values
|
||||
{
|
||||
core.MetaConfig{},
|
||||
true,
|
||||
},
|
||||
// invalid data
|
||||
{
|
||||
core.MetaConfig{
|
||||
AppName: strings.Repeat("a", 300),
|
||||
AppUrl: "test",
|
||||
SenderName: strings.Repeat("a", 300),
|
||||
SenderAddress: "invalid_email",
|
||||
UserVerificationUrl: "test",
|
||||
UserResetPasswordUrl: "test",
|
||||
UserConfirmEmailChangeUrl: "test",
|
||||
},
|
||||
true,
|
||||
},
|
||||
// invalid data (missing required placeholders)
|
||||
{
|
||||
core.MetaConfig{
|
||||
AppName: "test",
|
||||
AppUrl: "https://example.com",
|
||||
SenderName: "test",
|
||||
SenderAddress: "test@example.com",
|
||||
UserVerificationUrl: "https://example.com",
|
||||
UserResetPasswordUrl: "https://example.com",
|
||||
UserConfirmEmailChangeUrl: "https://example.com",
|
||||
},
|
||||
true,
|
||||
},
|
||||
// valid data
|
||||
{
|
||||
core.MetaConfig{
|
||||
AppName: "test",
|
||||
AppUrl: "https://example.com",
|
||||
SenderName: "test",
|
||||
SenderAddress: "test@example.com",
|
||||
UserVerificationUrl: "https://example.com/" + core.EmailPlaceholderToken,
|
||||
UserResetPasswordUrl: "https://example.com/" + core.EmailPlaceholderToken,
|
||||
UserConfirmEmailChangeUrl: "https://example.com/" + core.EmailPlaceholderToken,
|
||||
},
|
||||
false,
|
||||
},
|
||||
}
|
||||
|
||||
for i, scenario := range scenarios {
|
||||
result := scenario.config.Validate()
|
||||
|
||||
if result != nil && !scenario.expectError {
|
||||
t.Errorf("(%d) Didn't expect error, got %v", i, result)
|
||||
}
|
||||
|
||||
if result == nil && scenario.expectError {
|
||||
t.Errorf("(%d) Expected error, got nil", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogsConfigValidate(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
config core.LogsConfig
|
||||
expectError bool
|
||||
}{
|
||||
// zero values
|
||||
{
|
||||
core.LogsConfig{},
|
||||
false,
|
||||
},
|
||||
// invalid data
|
||||
{
|
||||
core.LogsConfig{MaxDays: -10},
|
||||
true,
|
||||
},
|
||||
// valid data
|
||||
{
|
||||
core.LogsConfig{MaxDays: 1},
|
||||
false,
|
||||
},
|
||||
}
|
||||
|
||||
for i, scenario := range scenarios {
|
||||
result := scenario.config.Validate()
|
||||
|
||||
if result != nil && !scenario.expectError {
|
||||
t.Errorf("(%d) Didn't expect error, got %v", i, result)
|
||||
}
|
||||
|
||||
if result == nil && scenario.expectError {
|
||||
t.Errorf("(%d) Expected error, got nil", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthProviderConfigValidate(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
config core.AuthProviderConfig
|
||||
expectError bool
|
||||
}{
|
||||
// zero values (disabled)
|
||||
{
|
||||
core.AuthProviderConfig{},
|
||||
false,
|
||||
},
|
||||
// zero values (enabled)
|
||||
{
|
||||
core.AuthProviderConfig{Enabled: true},
|
||||
true,
|
||||
},
|
||||
// invalid data
|
||||
{
|
||||
core.AuthProviderConfig{
|
||||
Enabled: true,
|
||||
ClientId: "",
|
||||
ClientSecret: "",
|
||||
AuthUrl: "test",
|
||||
TokenUrl: "test",
|
||||
UserApiUrl: "test",
|
||||
},
|
||||
true,
|
||||
},
|
||||
// valid data (only the required)
|
||||
{
|
||||
core.AuthProviderConfig{
|
||||
Enabled: true,
|
||||
ClientId: "test",
|
||||
ClientSecret: "test",
|
||||
},
|
||||
false,
|
||||
},
|
||||
// valid data (fill all fields)
|
||||
{
|
||||
core.AuthProviderConfig{
|
||||
Enabled: true,
|
||||
ClientId: "test",
|
||||
ClientSecret: "test",
|
||||
AuthUrl: "https://example.com",
|
||||
TokenUrl: "https://example.com",
|
||||
UserApiUrl: "https://example.com",
|
||||
},
|
||||
false,
|
||||
},
|
||||
}
|
||||
|
||||
for i, scenario := range scenarios {
|
||||
result := scenario.config.Validate()
|
||||
|
||||
if result != nil && !scenario.expectError {
|
||||
t.Errorf("(%d) Didn't expect error, got %v", i, result)
|
||||
}
|
||||
|
||||
if result == nil && scenario.expectError {
|
||||
t.Errorf("(%d) Expected error, got nil", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthProviderConfigSetupProvider(t *testing.T) {
|
||||
provider := auth.NewGithubProvider()
|
||||
|
||||
// disabled config
|
||||
c1 := core.AuthProviderConfig{Enabled: false}
|
||||
if err := c1.SetupProvider(provider); err == nil {
|
||||
t.Errorf("Expected error, got nil")
|
||||
}
|
||||
|
||||
c2 := core.AuthProviderConfig{
|
||||
Enabled: true,
|
||||
ClientId: "test_ClientId",
|
||||
ClientSecret: "test_ClientSecret",
|
||||
AuthUrl: "test_AuthUrl",
|
||||
UserApiUrl: "test_UserApiUrl",
|
||||
TokenUrl: "test_TokenUrl",
|
||||
}
|
||||
if err := c2.SetupProvider(provider); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
encoded, _ := json.Marshal(c2)
|
||||
expected := `{"enabled":true,"allowRegistrations":false,"clientId":"test_ClientId","clientSecret":"test_ClientSecret","authUrl":"test_AuthUrl","tokenUrl":"test_TokenUrl","userApiUrl":"test_UserApiUrl"}`
|
||||
if string(encoded) != expected {
|
||||
t.Errorf("Expected %s, got %s", expected, string(encoded))
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmailAuthConfigValidate(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
config core.EmailAuthConfig
|
||||
expectError bool
|
||||
}{
|
||||
// zero values (disabled)
|
||||
{
|
||||
core.EmailAuthConfig{},
|
||||
false,
|
||||
},
|
||||
// zero values (enabled)
|
||||
{
|
||||
core.EmailAuthConfig{Enabled: true},
|
||||
true,
|
||||
},
|
||||
// invalid data (only the required)
|
||||
{
|
||||
core.EmailAuthConfig{
|
||||
Enabled: true,
|
||||
MinPasswordLength: 4,
|
||||
},
|
||||
true,
|
||||
},
|
||||
// valid data (only the required)
|
||||
{
|
||||
core.EmailAuthConfig{
|
||||
Enabled: true,
|
||||
MinPasswordLength: 5,
|
||||
},
|
||||
false,
|
||||
},
|
||||
// invalid data (both OnlyDomains and ExceptDomains set)
|
||||
{
|
||||
core.EmailAuthConfig{
|
||||
Enabled: true,
|
||||
MinPasswordLength: 5,
|
||||
OnlyDomains: []string{"example.com", "test.com"},
|
||||
ExceptDomains: []string{"example.com", "test.com"},
|
||||
},
|
||||
true,
|
||||
},
|
||||
// valid data (only onlyDomains set)
|
||||
{
|
||||
core.EmailAuthConfig{
|
||||
Enabled: true,
|
||||
MinPasswordLength: 5,
|
||||
OnlyDomains: []string{"example.com", "test.com"},
|
||||
},
|
||||
false,
|
||||
},
|
||||
// valid data (only exceptDomains set)
|
||||
{
|
||||
core.EmailAuthConfig{
|
||||
Enabled: true,
|
||||
MinPasswordLength: 5,
|
||||
ExceptDomains: []string{"example.com", "test.com"},
|
||||
},
|
||||
false,
|
||||
},
|
||||
}
|
||||
|
||||
for i, scenario := range scenarios {
|
||||
result := scenario.config.Validate()
|
||||
|
||||
if result != nil && !scenario.expectError {
|
||||
t.Errorf("(%d) Didn't expect error, got %v", i, result)
|
||||
}
|
||||
|
||||
if result == nil && scenario.expectError {
|
||||
t.Errorf("(%d) Expected error, got nil", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
+12
-8
@@ -5,6 +5,7 @@ import (
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/tools/list"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
)
|
||||
|
||||
@@ -49,6 +50,7 @@ func (dao *Dao) FindAdminByEmail(email string) (*models.Admin, error) {
|
||||
//
|
||||
// Returns an error if the JWT token is invalid or expired.
|
||||
func (dao *Dao) FindAdminByToken(token string, baseTokenKey string) (*models.Admin, error) {
|
||||
// @todo consider caching the unverified claims
|
||||
unverifiedClaims, err := security.ParseUnverifiedJWT(token)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -86,20 +88,22 @@ func (dao *Dao) TotalAdmins() (int, error) {
|
||||
|
||||
// IsAdminEmailUnique checks if the provided email address is not
|
||||
// already in use by other admins.
|
||||
func (dao *Dao) IsAdminEmailUnique(email string, excludeId string) bool {
|
||||
func (dao *Dao) IsAdminEmailUnique(email string, excludeIds ...string) bool {
|
||||
if email == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
var exists bool
|
||||
err := dao.AdminQuery().
|
||||
Select("count(*)").
|
||||
AndWhere(dbx.Not(dbx.HashExp{"id": excludeId})).
|
||||
query := dao.AdminQuery().Select("count(*)").
|
||||
AndWhere(dbx.HashExp{"email": email}).
|
||||
Limit(1).
|
||||
Row(&exists)
|
||||
Limit(1)
|
||||
|
||||
return err == nil && !exists
|
||||
if uniqueExcludeIds := list.NonzeroUniques(excludeIds); len(uniqueExcludeIds) > 0 {
|
||||
query.AndWhere(dbx.NotIn("id", list.ToInterfaceSlice(uniqueExcludeIds)...))
|
||||
}
|
||||
|
||||
var exists bool
|
||||
|
||||
return query.Row(&exists) == nil && !exists
|
||||
}
|
||||
|
||||
// DeleteAdmin deletes the provided Admin model.
|
||||
|
||||
+32
-12
@@ -27,8 +27,9 @@ func TestFindAdminById(t *testing.T) {
|
||||
id string
|
||||
expectError bool
|
||||
}{
|
||||
{"00000000-2b4a-a26b-4d01-42d3c3d77bc8", true},
|
||||
{"3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8", false},
|
||||
{" ", true},
|
||||
{"missing", true},
|
||||
{"9q2trqumvlyr3bd", false},
|
||||
}
|
||||
|
||||
for i, scenario := range scenarios {
|
||||
@@ -53,6 +54,7 @@ func TestFindAdminByEmail(t *testing.T) {
|
||||
email string
|
||||
expectError bool
|
||||
}{
|
||||
{"", true},
|
||||
{"invalid", true},
|
||||
{"missing@example.com", true},
|
||||
{"test@example.com", false},
|
||||
@@ -83,23 +85,30 @@ func TestFindAdminByToken(t *testing.T) {
|
||||
expectedEmail string
|
||||
expectError bool
|
||||
}{
|
||||
// invalid base key (password reset key for auth token)
|
||||
// invalid auth token
|
||||
{
|
||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
|
||||
app.Settings().AdminPasswordResetToken.Secret,
|
||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTY0MDk5MTY2MX0.qrbkI2TITtFKMP6vrATrBVKPGjEiDIBeQ0mlqPGMVeY",
|
||||
app.Settings().AdminAuthToken.Secret,
|
||||
"",
|
||||
true,
|
||||
},
|
||||
// expired token
|
||||
{
|
||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTY0MDk5MTY2MX0.uXZ_ywsZeRFSvDNQ9zBoYUXKXw7VEr48Fzx-E06OkS8",
|
||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTY0MDk5MTY2MX0.I7w8iktkleQvC7_UIRpD7rNzcU4OnF7i7SFIUu6lD_4",
|
||||
app.Settings().AdminAuthToken.Secret,
|
||||
"",
|
||||
true,
|
||||
},
|
||||
// wrong base token (password reset token secret instead of auth secret)
|
||||
{
|
||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
app.Settings().AdminPasswordResetToken.Secret,
|
||||
"",
|
||||
true,
|
||||
},
|
||||
// valid token
|
||||
{
|
||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
|
||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
app.Settings().AdminAuthToken.Secret,
|
||||
"test@example.com",
|
||||
false,
|
||||
@@ -129,8 +138,8 @@ func TestTotalAdmins(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result1 != 2 {
|
||||
t.Fatalf("Expected 2 admins, got %d", result1)
|
||||
if result1 != 3 {
|
||||
t.Fatalf("Expected 3 admins, got %d", result1)
|
||||
}
|
||||
|
||||
// delete all
|
||||
@@ -156,8 +165,10 @@ func TestIsAdminEmailUnique(t *testing.T) {
|
||||
}{
|
||||
{"", "", false},
|
||||
{"test@example.com", "", false},
|
||||
{"test2@example.com", "", false},
|
||||
{"test3@example.com", "", false},
|
||||
{"new@example.com", "", true},
|
||||
{"test@example.com", "2b4a97cc-3f83-4d01-a26b-3d77bc842d3c", true},
|
||||
{"test@example.com", "sywbhecnh46rhm0", true},
|
||||
}
|
||||
|
||||
for i, scenario := range scenarios {
|
||||
@@ -186,15 +197,24 @@ func TestDeleteAdmin(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
admin3, err := app.Dao().FindAdminByEmail("test3@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
deleteErr1 := app.Dao().DeleteAdmin(admin1)
|
||||
if deleteErr1 != nil {
|
||||
t.Fatal(deleteErr1)
|
||||
}
|
||||
|
||||
// cannot delete the only remaining admin
|
||||
deleteErr2 := app.Dao().DeleteAdmin(admin2)
|
||||
if deleteErr2 == nil {
|
||||
if deleteErr2 != nil {
|
||||
t.Fatal(deleteErr2)
|
||||
}
|
||||
|
||||
// cannot delete the only remaining admin
|
||||
deleteErr3 := app.Dao().DeleteAdmin(admin3)
|
||||
if deleteErr3 == nil {
|
||||
t.Fatal("Expected delete error, got nil")
|
||||
}
|
||||
|
||||
|
||||
+161
-32
@@ -4,24 +4,44 @@
|
||||
package daos
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"golang.org/x/sync/semaphore"
|
||||
)
|
||||
|
||||
// New creates a new Dao instance with the provided db builder.
|
||||
const DefaultMaxFailRetries = 5
|
||||
|
||||
// New creates a new Dao instance with the provided db builder
|
||||
// (for both async and sync db operations).
|
||||
func New(db dbx.Builder) *Dao {
|
||||
return NewMultiDB(db, db)
|
||||
}
|
||||
|
||||
// New creates a new Dao instance with the provided dedicated
|
||||
// async and sync db builders.
|
||||
func NewMultiDB(concurrentDB, nonconcurrentDB dbx.Builder) *Dao {
|
||||
return &Dao{
|
||||
db: db,
|
||||
concurrentDB: concurrentDB,
|
||||
nonconcurrentDB: nonconcurrentDB,
|
||||
}
|
||||
}
|
||||
|
||||
// Dao handles various db operations.
|
||||
// Think of Dao as a repository and service layer in one.
|
||||
type Dao struct {
|
||||
db dbx.Builder
|
||||
// in a transaction both refer to the same *dbx.TX instance
|
||||
concurrentDB dbx.Builder
|
||||
nonconcurrentDB dbx.Builder
|
||||
|
||||
// @todo delete after removing Block and Continue
|
||||
sem *semaphore.Weighted
|
||||
mux sync.RWMutex
|
||||
|
||||
BeforeCreateFunc func(eventDao *Dao, m models.Model) error
|
||||
AfterCreateFunc func(eventDao *Dao, m models.Model)
|
||||
@@ -31,16 +51,78 @@ type Dao struct {
|
||||
AfterDeleteFunc func(eventDao *Dao, m models.Model)
|
||||
}
|
||||
|
||||
// DB returns the internal db builder (*dbx.DB or *dbx.TX).
|
||||
// DB returns the default dao db builder (*dbx.DB or *dbx.TX).
|
||||
//
|
||||
// Currently the default db builder is dao.concurrentDB but that may change in the future.
|
||||
func (dao *Dao) DB() dbx.Builder {
|
||||
return dao.db
|
||||
return dao.ConcurrentDB()
|
||||
}
|
||||
|
||||
// ConcurrentDB returns the dao concurrent (aka. multiple open connections)
|
||||
// db builder (*dbx.DB or *dbx.TX).
|
||||
//
|
||||
// In a transaction the concurrentDB and nonconcurrentDB refer to the same *dbx.TX instance.
|
||||
func (dao *Dao) ConcurrentDB() dbx.Builder {
|
||||
return dao.concurrentDB
|
||||
}
|
||||
|
||||
// NonconcurrentDB returns the dao nonconcurrent (aka. single open connection)
|
||||
// db builder (*dbx.DB or *dbx.TX).
|
||||
//
|
||||
// In a transaction the concurrentDB and nonconcurrentDB refer to the same *dbx.TX instance.
|
||||
func (dao *Dao) NonconcurrentDB() dbx.Builder {
|
||||
return dao.nonconcurrentDB
|
||||
}
|
||||
|
||||
// Deprecated: Will be removed in the next releases. Use [Dao.NonconcurrentDB()] instead.
|
||||
//
|
||||
// Block acquires a lock and blocks all other go routines that uses
|
||||
// the Dao instance until dao.Continue() is called, effectively making
|
||||
// the concurrent requests to perform synchronous db operations.
|
||||
//
|
||||
// This method should be used only as a last resort and as a workaround
|
||||
// for the SQLITE_BUSY error when mixing read&write in a transaction.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// func someLongRunningTransaction() error {
|
||||
// ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
// defer cancel()
|
||||
// if err := app.Dao().Block(ctx); err != nil {
|
||||
// return err
|
||||
// }
|
||||
// defer app.Dao().Continue()
|
||||
//
|
||||
// return app.Dao().RunInTransaction(func (txDao *daos.Dao) error {
|
||||
// // some long running read&write transaction...
|
||||
// })
|
||||
// }
|
||||
func (dao *Dao) Block(ctx context.Context) error {
|
||||
if dao.sem == nil {
|
||||
dao.mux.Lock()
|
||||
dao.sem = semaphore.NewWeighted(1)
|
||||
dao.mux.Unlock()
|
||||
}
|
||||
|
||||
return dao.sem.Acquire(ctx, 1)
|
||||
}
|
||||
|
||||
// Deprecated: Will be removed in the next releases. Use [Dao.NonconcurrentDB()] instead.
|
||||
//
|
||||
// Continue releases the previously acquired Block() lock.
|
||||
func (dao *Dao) Continue() {
|
||||
if dao.sem == nil {
|
||||
return
|
||||
}
|
||||
|
||||
dao.sem.Release(1)
|
||||
}
|
||||
|
||||
// ModelQuery creates a new query with preset Select and From fields
|
||||
// based on the provided model argument.
|
||||
func (dao *Dao) ModelQuery(m models.Model) *dbx.SelectQuery {
|
||||
tableName := m.TableName()
|
||||
return dao.db.Select(fmt.Sprintf("{{%s}}.*", tableName)).From(tableName)
|
||||
return dao.DB().Select("{{" + tableName + "}}.*").From(tableName)
|
||||
}
|
||||
|
||||
// FindById finds a single db record with the specified id and
|
||||
@@ -57,13 +139,23 @@ type afterCallGroup struct {
|
||||
|
||||
// RunInTransaction wraps fn into a transaction.
|
||||
//
|
||||
// It is safe to nest RunInTransaction calls.
|
||||
// It is safe to nest RunInTransaction calls as long as you use the txDao.
|
||||
func (dao *Dao) RunInTransaction(fn func(txDao *Dao) error) error {
|
||||
switch txOrDB := dao.db.(type) {
|
||||
switch txOrDB := dao.NonconcurrentDB().(type) {
|
||||
case *dbx.Tx:
|
||||
// nested transactions are not supported by default
|
||||
// so execute the function within the current transaction
|
||||
return fn(dao)
|
||||
// ---
|
||||
// create a new dao with the same hooks to avoid semaphore deadlock when nesting
|
||||
txDao := New(txOrDB)
|
||||
txDao.BeforeCreateFunc = dao.BeforeCreateFunc
|
||||
txDao.BeforeUpdateFunc = dao.BeforeUpdateFunc
|
||||
txDao.BeforeDeleteFunc = dao.BeforeDeleteFunc
|
||||
txDao.AfterCreateFunc = dao.AfterCreateFunc
|
||||
txDao.AfterUpdateFunc = dao.AfterUpdateFunc
|
||||
txDao.AfterDeleteFunc = dao.AfterDeleteFunc
|
||||
|
||||
return fn(txDao)
|
||||
case *dbx.DB:
|
||||
afterCalls := []afterCallGroup{}
|
||||
|
||||
@@ -107,14 +199,15 @@ func (dao *Dao) RunInTransaction(fn func(txDao *Dao) error) error {
|
||||
|
||||
if txError == nil {
|
||||
// execute after event calls on successful transaction
|
||||
// (note: using the non-transaction dao to allow following queries in the after hooks)
|
||||
for _, call := range afterCalls {
|
||||
switch call.Action {
|
||||
case "create":
|
||||
dao.AfterCreateFunc(call.EventDao, call.Model)
|
||||
dao.AfterCreateFunc(dao, call.Model)
|
||||
case "update":
|
||||
dao.AfterUpdateFunc(call.EventDao, call.Model)
|
||||
dao.AfterUpdateFunc(dao, call.Model)
|
||||
case "delete":
|
||||
dao.AfterDeleteFunc(call.EventDao, call.Model)
|
||||
dao.AfterDeleteFunc(dao, call.Model)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -122,7 +215,7 @@ func (dao *Dao) RunInTransaction(fn func(txDao *Dao) error) error {
|
||||
return txError
|
||||
}
|
||||
|
||||
return errors.New("Failed to start transaction (unknown dao.db)")
|
||||
return errors.New("failed to start transaction (unknown dao.NonconcurrentDB() instance)")
|
||||
}
|
||||
|
||||
// Delete deletes the provided model.
|
||||
@@ -131,30 +224,36 @@ func (dao *Dao) Delete(m models.Model) error {
|
||||
return errors.New("ID is not set")
|
||||
}
|
||||
|
||||
if dao.BeforeDeleteFunc != nil {
|
||||
if err := dao.BeforeDeleteFunc(dao, m); err != nil {
|
||||
return dao.failRetry(func(retryDao *Dao) error {
|
||||
if retryDao.BeforeDeleteFunc != nil {
|
||||
if err := retryDao.BeforeDeleteFunc(retryDao, m); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := retryDao.NonconcurrentDB().Model(m).Delete(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := dao.db.Model(m).Delete(); err != nil {
|
||||
return err
|
||||
}
|
||||
if retryDao.AfterDeleteFunc != nil {
|
||||
retryDao.AfterDeleteFunc(retryDao, m)
|
||||
}
|
||||
|
||||
if dao.AfterDeleteFunc != nil {
|
||||
dao.AfterDeleteFunc(dao, m)
|
||||
}
|
||||
|
||||
return nil
|
||||
return nil
|
||||
}, DefaultMaxFailRetries)
|
||||
}
|
||||
|
||||
// Save upserts (update or create if primary key is not set) the provided model.
|
||||
func (dao *Dao) Save(m models.Model) error {
|
||||
if m.IsNew() {
|
||||
return dao.create(m)
|
||||
return dao.failRetry(func(retryDao *Dao) error {
|
||||
return retryDao.create(m)
|
||||
}, DefaultMaxFailRetries)
|
||||
}
|
||||
|
||||
return dao.update(m)
|
||||
return dao.failRetry(func(retryDao *Dao) error {
|
||||
return retryDao.update(m)
|
||||
}, DefaultMaxFailRetries)
|
||||
}
|
||||
|
||||
func (dao *Dao) update(m models.Model) error {
|
||||
@@ -177,7 +276,7 @@ func (dao *Dao) update(m models.Model) error {
|
||||
if v, ok := any(m).(models.ColumnValueMapper); ok {
|
||||
dataMap := v.ColumnValueMap()
|
||||
|
||||
_, err := dao.db.Update(
|
||||
_, err := dao.NonconcurrentDB().Update(
|
||||
m.TableName(),
|
||||
dataMap,
|
||||
dbx.HashExp{"id": m.GetId()},
|
||||
@@ -187,7 +286,7 @@ func (dao *Dao) update(m models.Model) error {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if err := dao.db.Model(m).Update(); err != nil {
|
||||
if err := dao.NonconcurrentDB().Model(m).Update(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -228,18 +327,18 @@ func (dao *Dao) create(m models.Model) error {
|
||||
dataMap["id"] = m.GetId()
|
||||
}
|
||||
|
||||
_, err := dao.db.Insert(m.TableName(), dataMap).Execute()
|
||||
_, err := dao.NonconcurrentDB().Insert(m.TableName(), dataMap).Execute()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if err := dao.db.Model(m).Insert(); err != nil {
|
||||
if err := dao.NonconcurrentDB().Model(m).Insert(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// clears the "new" model flag
|
||||
m.UnmarkAsNew()
|
||||
m.MarkAsNotNew()
|
||||
|
||||
if dao.AfterCreateFunc != nil {
|
||||
dao.AfterCreateFunc(dao, m)
|
||||
@@ -247,3 +346,33 @@ func (dao *Dao) create(m models.Model) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (dao *Dao) failRetry(op func(retryDao *Dao) error, maxRetries int) error {
|
||||
retryDao := dao
|
||||
attempts := 1
|
||||
|
||||
Retry:
|
||||
if attempts == 2 {
|
||||
// assign new Dao without the before hooks to avoid triggering
|
||||
// the already fired before callbacks multiple times
|
||||
retryDao = NewMultiDB(dao.concurrentDB, dao.nonconcurrentDB)
|
||||
retryDao.AfterCreateFunc = dao.AfterCreateFunc
|
||||
retryDao.AfterUpdateFunc = dao.AfterUpdateFunc
|
||||
retryDao.AfterDeleteFunc = dao.AfterDeleteFunc
|
||||
}
|
||||
|
||||
// execute
|
||||
err := op(retryDao)
|
||||
|
||||
if err != nil &&
|
||||
attempts < maxRetries &&
|
||||
// note: we are checking the err message so that we can handle both the cgo and noncgo errors
|
||||
strings.Contains(err.Error(), "database is locked") {
|
||||
// wait and retry
|
||||
time.Sleep(time.Duration(200*attempts) * time.Millisecond)
|
||||
attempts++
|
||||
goto Retry
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
+190
-7
@@ -20,6 +20,25 @@ func TestNew(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewMultiDB(t *testing.T) {
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
|
||||
dao := daos.NewMultiDB(testApp.Dao().ConcurrentDB(), testApp.Dao().NonconcurrentDB())
|
||||
|
||||
if dao.DB() != testApp.Dao().ConcurrentDB() {
|
||||
t.Fatal("[db-concurrentDB] The 2 db instances are different")
|
||||
}
|
||||
|
||||
if dao.ConcurrentDB() != testApp.Dao().ConcurrentDB() {
|
||||
t.Fatal("[concurrentDB-concurrentDB] The 2 db instances are different")
|
||||
}
|
||||
|
||||
if dao.NonconcurrentDB() != testApp.Dao().NonconcurrentDB() {
|
||||
t.Fatal("[nonconcurrentDB-nonconcurrentDB] The 2 db instances are different")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDaoModelQuery(t *testing.T) {
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
@@ -35,8 +54,8 @@ func TestDaoModelQuery(t *testing.T) {
|
||||
"SELECT {{_collections}}.* FROM `_collections`",
|
||||
},
|
||||
{
|
||||
&models.User{},
|
||||
"SELECT {{_users}}.* FROM `_users`",
|
||||
&models.Admin{},
|
||||
"SELECT {{_admins}}.* FROM `_admins`",
|
||||
},
|
||||
{
|
||||
&models.Request{},
|
||||
@@ -64,19 +83,19 @@ func TestDaoFindById(t *testing.T) {
|
||||
// missing id
|
||||
{
|
||||
&models.Collection{},
|
||||
"00000000-075d-49fe-9d09-ea7e951000dc",
|
||||
"missing",
|
||||
true,
|
||||
},
|
||||
// existing collection id
|
||||
{
|
||||
&models.Collection{},
|
||||
"3f2888f8-075d-49fe-9d09-ea7e951000dc",
|
||||
"wsmn24bux7wo113",
|
||||
false,
|
||||
},
|
||||
// existing user id
|
||||
// existing admin id
|
||||
{
|
||||
&models.User{},
|
||||
"97cc3d3d-6ba2-383f-b42a-7bc84d27410c",
|
||||
&models.Admin{},
|
||||
"sbmbsdb40jyxf7h",
|
||||
false,
|
||||
},
|
||||
}
|
||||
@@ -289,6 +308,170 @@ func TestDaoDelete(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDaoRetryCreate(t *testing.T) {
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
|
||||
// init mock retry dao
|
||||
retryBeforeCreateHookCalls := 0
|
||||
retryAfterCreateHookCalls := 0
|
||||
retryDao := daos.New(testApp.DB())
|
||||
retryDao.BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
retryBeforeCreateHookCalls++
|
||||
return errors.New("database is locked")
|
||||
}
|
||||
retryDao.AfterCreateFunc = func(eventDao *daos.Dao, m models.Model) {
|
||||
retryAfterCreateHookCalls++
|
||||
}
|
||||
|
||||
model := &models.Admin{Email: "new@example.com"}
|
||||
if err := retryDao.Save(model); err != nil {
|
||||
t.Fatalf("Expected nil after retry, got error: %v", err)
|
||||
}
|
||||
|
||||
// the before hook is expected to be called only once because
|
||||
// it is ignored after the first "database is locked" error
|
||||
if retryBeforeCreateHookCalls != 1 {
|
||||
t.Fatalf("Expected before hook calls to be 1, got %d", retryBeforeCreateHookCalls)
|
||||
}
|
||||
|
||||
if retryAfterCreateHookCalls != 1 {
|
||||
t.Fatalf("Expected after hook calls to be 1, got %d", retryAfterCreateHookCalls)
|
||||
}
|
||||
|
||||
// with non-locking error
|
||||
retryBeforeCreateHookCalls = 0
|
||||
retryAfterCreateHookCalls = 0
|
||||
retryDao.BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
retryBeforeCreateHookCalls++
|
||||
return errors.New("non-locking error")
|
||||
}
|
||||
|
||||
dummy := &models.Admin{Email: "test@example.com"}
|
||||
if err := retryDao.Save(dummy); err == nil {
|
||||
t.Fatal("Expected error, got nil")
|
||||
}
|
||||
|
||||
if retryBeforeCreateHookCalls != 1 {
|
||||
t.Fatalf("Expected before hook calls to be 1, got %d", retryBeforeCreateHookCalls)
|
||||
}
|
||||
|
||||
if retryAfterCreateHookCalls != 0 {
|
||||
t.Fatalf("Expected after hook calls to be 0, got %d", retryAfterCreateHookCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDaoRetryUpdate(t *testing.T) {
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
|
||||
model, err := testApp.Dao().FindAdminByEmail("test@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// init mock retry dao
|
||||
retryBeforeUpdateHookCalls := 0
|
||||
retryAfterUpdateHookCalls := 0
|
||||
retryDao := daos.New(testApp.DB())
|
||||
retryDao.BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
retryBeforeUpdateHookCalls++
|
||||
return errors.New("database is locked")
|
||||
}
|
||||
retryDao.AfterUpdateFunc = func(eventDao *daos.Dao, m models.Model) {
|
||||
retryAfterUpdateHookCalls++
|
||||
}
|
||||
|
||||
if err := retryDao.Save(model); err != nil {
|
||||
t.Fatalf("Expected nil after retry, got error: %v", err)
|
||||
}
|
||||
|
||||
// the before hook is expected to be called only once because
|
||||
// it is ignored after the first "database is locked" error
|
||||
if retryBeforeUpdateHookCalls != 1 {
|
||||
t.Fatalf("Expected before hook calls to be 1, got %d", retryBeforeUpdateHookCalls)
|
||||
}
|
||||
|
||||
if retryAfterUpdateHookCalls != 1 {
|
||||
t.Fatalf("Expected after hook calls to be 1, got %d", retryAfterUpdateHookCalls)
|
||||
}
|
||||
|
||||
// with non-locking error
|
||||
retryBeforeUpdateHookCalls = 0
|
||||
retryAfterUpdateHookCalls = 0
|
||||
retryDao.BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
retryBeforeUpdateHookCalls++
|
||||
return errors.New("non-locking error")
|
||||
}
|
||||
|
||||
if err := retryDao.Save(model); err == nil {
|
||||
t.Fatal("Expected error, got nil")
|
||||
}
|
||||
|
||||
if retryBeforeUpdateHookCalls != 1 {
|
||||
t.Fatalf("Expected before hook calls to be 1, got %d", retryBeforeUpdateHookCalls)
|
||||
}
|
||||
|
||||
if retryAfterUpdateHookCalls != 0 {
|
||||
t.Fatalf("Expected after hook calls to be 0, got %d", retryAfterUpdateHookCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDaoRetryDelete(t *testing.T) {
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
|
||||
// init mock retry dao
|
||||
retryBeforeDeleteHookCalls := 0
|
||||
retryAfterDeleteHookCalls := 0
|
||||
retryDao := daos.New(testApp.DB())
|
||||
retryDao.BeforeDeleteFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
retryBeforeDeleteHookCalls++
|
||||
return errors.New("database is locked")
|
||||
}
|
||||
retryDao.AfterDeleteFunc = func(eventDao *daos.Dao, m models.Model) {
|
||||
retryAfterDeleteHookCalls++
|
||||
}
|
||||
|
||||
model, _ := retryDao.FindAdminByEmail("test@example.com")
|
||||
if err := retryDao.Delete(model); err != nil {
|
||||
t.Fatalf("Expected nil after retry, got error: %v", err)
|
||||
}
|
||||
|
||||
// the before hook is expected to be called only once because
|
||||
// it is ignored after the first "database is locked" error
|
||||
if retryBeforeDeleteHookCalls != 1 {
|
||||
t.Fatalf("Expected before hook calls to be 1, got %d", retryBeforeDeleteHookCalls)
|
||||
}
|
||||
|
||||
if retryAfterDeleteHookCalls != 1 {
|
||||
t.Fatalf("Expected after hook calls to be 1, got %d", retryAfterDeleteHookCalls)
|
||||
}
|
||||
|
||||
// with non-locking error
|
||||
retryBeforeDeleteHookCalls = 0
|
||||
retryAfterDeleteHookCalls = 0
|
||||
retryDao.BeforeDeleteFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
retryBeforeDeleteHookCalls++
|
||||
return errors.New("non-locking error")
|
||||
}
|
||||
|
||||
dummy := &models.Admin{}
|
||||
dummy.RefreshId()
|
||||
dummy.MarkAsNotNew()
|
||||
if err := retryDao.Delete(dummy); err == nil {
|
||||
t.Fatal("Expected error, got nil")
|
||||
}
|
||||
|
||||
if retryBeforeDeleteHookCalls != 1 {
|
||||
t.Fatalf("Expected before hook calls to be 1, got %d", retryBeforeDeleteHookCalls)
|
||||
}
|
||||
|
||||
if retryAfterDeleteHookCalls != 0 {
|
||||
t.Fatalf("Expected after hook calls to be 0, got %d", retryAfterDeleteHookCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDaoBeforeHooksError(t *testing.T) {
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
|
||||
+53
-35
@@ -8,6 +8,7 @@ import (
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/models/schema"
|
||||
"github.com/pocketbase/pocketbase/tools/list"
|
||||
)
|
||||
|
||||
// CollectionQuery returns a new Collection select query.
|
||||
@@ -15,15 +16,31 @@ func (dao *Dao) CollectionQuery() *dbx.SelectQuery {
|
||||
return dao.ModelQuery(&models.Collection{})
|
||||
}
|
||||
|
||||
// FindCollectionByNameOrId finds the first collection by its name or id.
|
||||
// FindCollectionsByType finds all collections by the given type.
|
||||
func (dao *Dao) FindCollectionsByType(collectionType string) ([]*models.Collection, error) {
|
||||
collections := []*models.Collection{}
|
||||
|
||||
err := dao.CollectionQuery().
|
||||
AndWhere(dbx.HashExp{"type": collectionType}).
|
||||
OrderBy("created ASC").
|
||||
All(&collections)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return collections, nil
|
||||
}
|
||||
|
||||
// FindCollectionByNameOrId finds a single collection by its name (case insensitive) or id.
|
||||
func (dao *Dao) FindCollectionByNameOrId(nameOrId string) (*models.Collection, error) {
|
||||
model := &models.Collection{}
|
||||
|
||||
err := dao.CollectionQuery().
|
||||
AndWhere(dbx.Or(
|
||||
dbx.HashExp{"id": nameOrId},
|
||||
dbx.HashExp{"name": nameOrId},
|
||||
)).
|
||||
AndWhere(dbx.NewExp("[[id]] = {:id} OR LOWER([[name]])={:name}", dbx.Params{
|
||||
"id": nameOrId,
|
||||
"name": strings.ToLower(nameOrId),
|
||||
})).
|
||||
Limit(1).
|
||||
One(model)
|
||||
|
||||
@@ -37,58 +54,47 @@ func (dao *Dao) FindCollectionByNameOrId(nameOrId string) (*models.Collection, e
|
||||
// IsCollectionNameUnique checks that there is no existing collection
|
||||
// with the provided name (case insensitive!).
|
||||
//
|
||||
// Note: case sensitive check because the name is used also as a table name for the records.
|
||||
func (dao *Dao) IsCollectionNameUnique(name string, excludeId string) bool {
|
||||
// Note: case insensitive check because the name is used also as a table name for the records.
|
||||
func (dao *Dao) IsCollectionNameUnique(name string, excludeIds ...string) bool {
|
||||
if name == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
var exists bool
|
||||
err := dao.CollectionQuery().
|
||||
query := dao.CollectionQuery().
|
||||
Select("count(*)").
|
||||
AndWhere(dbx.Not(dbx.HashExp{"id": excludeId})).
|
||||
AndWhere(dbx.NewExp("LOWER([[name]])={:name}", dbx.Params{"name": strings.ToLower(name)})).
|
||||
Limit(1).
|
||||
Row(&exists)
|
||||
Limit(1)
|
||||
|
||||
return err == nil && !exists
|
||||
}
|
||||
if uniqueExcludeIds := list.NonzeroUniques(excludeIds); len(uniqueExcludeIds) > 0 {
|
||||
query.AndWhere(dbx.NotIn("id", list.ToInterfaceSlice(uniqueExcludeIds)...))
|
||||
}
|
||||
|
||||
// FindCollectionsWithUserFields finds all collections that has
|
||||
// at least one user schema field.
|
||||
func (dao *Dao) FindCollectionsWithUserFields() ([]*models.Collection, error) {
|
||||
result := []*models.Collection{}
|
||||
var exists bool
|
||||
|
||||
err := dao.CollectionQuery().
|
||||
InnerJoin(
|
||||
"json_each(schema) as jsonField",
|
||||
dbx.NewExp(
|
||||
"json_extract(jsonField.value, '$.type') = {:type}",
|
||||
dbx.Params{"type": schema.FieldTypeUser},
|
||||
),
|
||||
).
|
||||
All(&result)
|
||||
|
||||
return result, err
|
||||
return query.Row(&exists) == nil && !exists
|
||||
}
|
||||
|
||||
// FindCollectionReferences returns information for all
|
||||
// relation schema fields referencing the provided collection.
|
||||
//
|
||||
// If the provided collection has reference to itself then it will be
|
||||
// also included in the result. To exlude it, pass the collection id
|
||||
// also included in the result. To exclude it, pass the collection id
|
||||
// as the excludeId argument.
|
||||
func (dao *Dao) FindCollectionReferences(collection *models.Collection, excludeId string) (map[*models.Collection][]*schema.SchemaField, error) {
|
||||
func (dao *Dao) FindCollectionReferences(collection *models.Collection, excludeIds ...string) (map[*models.Collection][]*schema.SchemaField, error) {
|
||||
collections := []*models.Collection{}
|
||||
|
||||
err := dao.CollectionQuery().
|
||||
AndWhere(dbx.Not(dbx.HashExp{"id": excludeId})).
|
||||
All(&collections)
|
||||
if err != nil {
|
||||
query := dao.CollectionQuery()
|
||||
|
||||
if uniqueExcludeIds := list.NonzeroUniques(excludeIds); len(uniqueExcludeIds) > 0 {
|
||||
query.AndWhere(dbx.NotIn("id", list.ToInterfaceSlice(uniqueExcludeIds)...))
|
||||
}
|
||||
|
||||
if err := query.All(&collections); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := map[*models.Collection][]*schema.SchemaField{}
|
||||
|
||||
for _, c := range collections {
|
||||
for _, f := range c.Schema.Fields() {
|
||||
if f.Type != schema.FieldTypeRelation {
|
||||
@@ -152,6 +158,11 @@ func (dao *Dao) SaveCollection(collection *models.Collection) error {
|
||||
}
|
||||
|
||||
return dao.RunInTransaction(func(txDao *Dao) error {
|
||||
// set default collection type
|
||||
if collection.Type == "" {
|
||||
collection.Type = models.CollectionTypeBase
|
||||
}
|
||||
|
||||
// persist the collection model
|
||||
if err := txDao.Save(collection); err != nil {
|
||||
return err
|
||||
@@ -196,7 +207,14 @@ func (dao *Dao) ImportCollections(
|
||||
imported.RefreshId()
|
||||
}
|
||||
|
||||
// set default type if missing
|
||||
if imported.Type == "" {
|
||||
imported.Type = models.CollectionTypeBase
|
||||
}
|
||||
|
||||
if existing, ok := mappedExisting[imported.GetId()]; ok {
|
||||
imported.MarkAsNotNew()
|
||||
|
||||
// preserve original created date
|
||||
if !existing.Created.IsZero() {
|
||||
imported.Created = existing.Created
|
||||
|
||||
+120
-124
@@ -3,6 +3,7 @@ package daos_test
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
@@ -24,6 +25,41 @@ func TestCollectionQuery(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindCollectionsByType(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
scenarios := []struct {
|
||||
collectionType string
|
||||
expectError bool
|
||||
expectTotal int
|
||||
}{
|
||||
{"", false, 0},
|
||||
{"unknown", false, 0},
|
||||
{models.CollectionTypeAuth, false, 3},
|
||||
{models.CollectionTypeBase, false, 5},
|
||||
}
|
||||
|
||||
for i, scenario := range scenarios {
|
||||
collections, err := app.Dao().FindCollectionsByType(scenario.collectionType)
|
||||
|
||||
hasErr := err != nil
|
||||
if hasErr != scenario.expectError {
|
||||
t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, scenario.expectError, hasErr, err)
|
||||
}
|
||||
|
||||
if len(collections) != scenario.expectTotal {
|
||||
t.Errorf("(%d) Expected %d collections, got %d", i, scenario.expectTotal, len(collections))
|
||||
}
|
||||
|
||||
for _, c := range collections {
|
||||
if c.Type != scenario.collectionType {
|
||||
t.Errorf("(%d) Expected collection with type %s, got %s: \n%v", i, scenario.collectionType, c.Type, c)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindCollectionByNameOrId(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
@@ -34,9 +70,9 @@ func TestFindCollectionByNameOrId(t *testing.T) {
|
||||
}{
|
||||
{"", true},
|
||||
{"missing", true},
|
||||
{"00000000-075d-49fe-9d09-ea7e951000dc", true},
|
||||
{"3f2888f8-075d-49fe-9d09-ea7e951000dc", false},
|
||||
{"demo", false},
|
||||
{"wsmn24bux7wo113", false},
|
||||
{"demo1", false},
|
||||
{"DEMO1", false}, // case insensitive check
|
||||
}
|
||||
|
||||
for i, scenario := range scenarios {
|
||||
@@ -47,7 +83,7 @@ func TestFindCollectionByNameOrId(t *testing.T) {
|
||||
t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, scenario.expectError, hasErr, err)
|
||||
}
|
||||
|
||||
if model != nil && model.Id != scenario.nameOrId && model.Name != scenario.nameOrId {
|
||||
if model != nil && model.Id != scenario.nameOrId && !strings.EqualFold(model.Name, scenario.nameOrId) {
|
||||
t.Errorf("(%d) Expected model with identifier %s, got %v", i, scenario.nameOrId, model)
|
||||
}
|
||||
}
|
||||
@@ -63,9 +99,10 @@ func TestIsCollectionNameUnique(t *testing.T) {
|
||||
expected bool
|
||||
}{
|
||||
{"", "", false},
|
||||
{"demo", "", false},
|
||||
{"demo1", "", false},
|
||||
{"Demo1", "", false},
|
||||
{"new", "", true},
|
||||
{"demo", "3f2888f8-075d-49fe-9d09-ea7e951000dc", true},
|
||||
{"demo1", "wsmn24bux7wo113", true},
|
||||
}
|
||||
|
||||
for i, scenario := range scenarios {
|
||||
@@ -76,38 +113,22 @@ func TestIsCollectionNameUnique(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindCollectionsWithUserFields(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
result, err := app.Dao().FindCollectionsWithUserFields()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
expectedNames := []string{"demo2", models.ProfileCollectionName}
|
||||
|
||||
if len(result) != len(expectedNames) {
|
||||
t.Fatalf("Expected collections %v, got %v", expectedNames, result)
|
||||
}
|
||||
|
||||
for i, col := range result {
|
||||
if !list.ExistInSlice(col.Name, expectedNames) {
|
||||
t.Errorf("(%d) Couldn't find %s in %v", i, col.Name, expectedNames)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindCollectionReferences(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
collection, err := app.Dao().FindCollectionByNameOrId("demo")
|
||||
collection, err := app.Dao().FindCollectionByNameOrId("demo3")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
result, err := app.Dao().FindCollectionReferences(collection, collection.Id)
|
||||
result, err := app.Dao().FindCollectionReferences(
|
||||
collection,
|
||||
collection.Id,
|
||||
// test whether "nonempty" exclude ids condition will be skipped
|
||||
"",
|
||||
"",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -116,11 +137,18 @@ func TestFindCollectionReferences(t *testing.T) {
|
||||
t.Fatalf("Expected 1 collection, got %d: %v", len(result), result)
|
||||
}
|
||||
|
||||
expectedFields := []string{"onerel", "manyrels", "cascaderel"}
|
||||
expectedFields := []string{
|
||||
"rel_one_no_cascade",
|
||||
"rel_one_no_cascade_required",
|
||||
"rel_one_cascade",
|
||||
"rel_many_no_cascade",
|
||||
"rel_many_no_cascade_required",
|
||||
"rel_many_cascade",
|
||||
}
|
||||
|
||||
for col, fields := range result {
|
||||
if col.Name != "demo2" {
|
||||
t.Fatalf("Expected collection demo2, got %s", col.Name)
|
||||
if col.Name != "demo4" {
|
||||
t.Fatalf("Expected collection demo4, got %s", col.Name)
|
||||
}
|
||||
if len(fields) != len(expectedFields) {
|
||||
t.Fatalf("Expected fields %v, got %v", expectedFields, fields)
|
||||
@@ -138,7 +166,7 @@ func TestDeleteCollection(t *testing.T) {
|
||||
defer app.Cleanup()
|
||||
|
||||
c0 := &models.Collection{}
|
||||
c1, err := app.Dao().FindCollectionByNameOrId("demo")
|
||||
c1, err := app.Dao().FindCollectionByNameOrId("clients")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -146,18 +174,22 @@ func TestDeleteCollection(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c3, err := app.Dao().FindCollectionByNameOrId(models.ProfileCollectionName)
|
||||
c3, err := app.Dao().FindCollectionByNameOrId("demo1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c3.System = true
|
||||
if err := app.Dao().Save(c3); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
scenarios := []struct {
|
||||
model *models.Collection
|
||||
expectError bool
|
||||
}{
|
||||
{c0, true},
|
||||
{c1, true}, // is part of a reference
|
||||
{c2, false},
|
||||
{c1, false},
|
||||
{c2, true}, // is part of a reference
|
||||
{c3, true}, // system
|
||||
}
|
||||
|
||||
@@ -177,6 +209,7 @@ func TestSaveCollectionCreate(t *testing.T) {
|
||||
|
||||
collection := &models.Collection{
|
||||
Name: "new_test",
|
||||
Type: models.CollectionTypeBase,
|
||||
Schema: schema.NewSchema(
|
||||
&schema.SchemaField{
|
||||
Type: schema.FieldTypeText,
|
||||
@@ -239,7 +272,7 @@ func TestSaveCollectionUpdate(t *testing.T) {
|
||||
}
|
||||
|
||||
// check if the records table has the schema fields
|
||||
expectedColumns := []string{"id", "created", "updated", "title_update", "test"}
|
||||
expectedColumns := []string{"id", "created", "updated", "title_update", "test", "files"}
|
||||
columns, err := app.Dao().GetTableColumns(collection.Name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -262,31 +295,24 @@ func TestImportCollections(t *testing.T) {
|
||||
beforeRecordsSync func(txDao *daos.Dao, mappedImported, mappedExisting map[string]*models.Collection) error
|
||||
expectError bool
|
||||
expectCollectionsCount int
|
||||
beforeTestFunc func(testApp *tests.TestApp, resultCollections []*models.Collection)
|
||||
afterTestFunc func(testApp *tests.TestApp, resultCollections []*models.Collection)
|
||||
}{
|
||||
{
|
||||
name: "empty collections",
|
||||
jsonData: `[]`,
|
||||
expectError: true,
|
||||
expectCollectionsCount: 5,
|
||||
},
|
||||
{
|
||||
name: "check db constraints",
|
||||
jsonData: `[
|
||||
{"name": "import_test", "schema": []}
|
||||
]`,
|
||||
deleteMissing: false,
|
||||
expectError: true,
|
||||
expectCollectionsCount: 5,
|
||||
expectCollectionsCount: 8,
|
||||
},
|
||||
{
|
||||
name: "minimal collection import",
|
||||
jsonData: `[
|
||||
{"name": "import_test", "schema": [{"name":"test", "type": "text"}]}
|
||||
{"name": "import_test1", "schema": [{"name":"test", "type": "text"}]},
|
||||
{"name": "import_test2", "type": "auth"}
|
||||
]`,
|
||||
deleteMissing: false,
|
||||
expectError: false,
|
||||
expectCollectionsCount: 6,
|
||||
expectCollectionsCount: 10,
|
||||
},
|
||||
{
|
||||
name: "minimal collection import + failed beforeRecordsSync",
|
||||
@@ -298,7 +324,7 @@ func TestImportCollections(t *testing.T) {
|
||||
},
|
||||
deleteMissing: false,
|
||||
expectError: true,
|
||||
expectCollectionsCount: 5,
|
||||
expectCollectionsCount: 8,
|
||||
},
|
||||
{
|
||||
name: "minimal collection import + successful beforeRecordsSync",
|
||||
@@ -310,13 +336,13 @@ func TestImportCollections(t *testing.T) {
|
||||
},
|
||||
deleteMissing: false,
|
||||
expectError: false,
|
||||
expectCollectionsCount: 6,
|
||||
expectCollectionsCount: 9,
|
||||
},
|
||||
{
|
||||
name: "new + update + delete system collection",
|
||||
jsonData: `[
|
||||
{
|
||||
"id":"3f2888f8-075d-49fe-9d09-ea7e951000dc",
|
||||
"id":"wsmn24bux7wo113",
|
||||
"name":"demo",
|
||||
"schema":[
|
||||
{
|
||||
@@ -346,50 +372,49 @@ func TestImportCollections(t *testing.T) {
|
||||
]`,
|
||||
deleteMissing: true,
|
||||
expectError: true,
|
||||
expectCollectionsCount: 5,
|
||||
expectCollectionsCount: 8,
|
||||
},
|
||||
{
|
||||
name: "new + update + delete non-system collection",
|
||||
jsonData: `[
|
||||
{
|
||||
"id":"abe78266-fd4d-4aea-962d-8c0138ac522b",
|
||||
"name":"profiles",
|
||||
"system":true,
|
||||
"listRule":"userId = @request.user.id",
|
||||
"viewRule":"created > 'test_change'",
|
||||
"createRule":"userId = @request.user.id",
|
||||
"updateRule":"userId = @request.user.id",
|
||||
"deleteRule":"userId = @request.user.id",
|
||||
"schema":[
|
||||
"id": "kpv709sk2lqbqk8",
|
||||
"system": true,
|
||||
"name": "nologin",
|
||||
"type": "auth",
|
||||
"options": {
|
||||
"allowEmailAuth": false,
|
||||
"allowOAuth2Auth": false,
|
||||
"allowUsernameAuth": false,
|
||||
"exceptEmailDomains": [],
|
||||
"manageRule": "@request.auth.collectionName = 'users'",
|
||||
"minPasswordLength": 8,
|
||||
"onlyEmailDomains": [],
|
||||
"requireEmail": true
|
||||
},
|
||||
"listRule": "",
|
||||
"viewRule": "",
|
||||
"createRule": "",
|
||||
"updateRule": "",
|
||||
"deleteRule": "",
|
||||
"schema": [
|
||||
{
|
||||
"id":"koih1lqx",
|
||||
"name":"userId",
|
||||
"type":"user",
|
||||
"system":true,
|
||||
"required":true,
|
||||
"unique":true,
|
||||
"options":{
|
||||
"maxSelect":1,
|
||||
"cascadeDelete":true
|
||||
}
|
||||
},
|
||||
{
|
||||
"id":"69ycbg3q",
|
||||
"name":"rel",
|
||||
"type":"relation",
|
||||
"system":false,
|
||||
"required":false,
|
||||
"unique":false,
|
||||
"options":{
|
||||
"maxSelect":2,
|
||||
"collectionId":"abe78266-fd4d-4aea-962d-8c0138ac522b",
|
||||
"cascadeDelete":false
|
||||
"id": "x8zzktwe",
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"system": false,
|
||||
"required": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"min": null,
|
||||
"max": null,
|
||||
"pattern": ""
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id":"3f2888f8-075d-49fe-9d09-ea7e951000dc",
|
||||
"id":"wsmn24bux7wo113",
|
||||
"name":"demo",
|
||||
"schema":[
|
||||
{
|
||||
@@ -427,38 +452,8 @@ func TestImportCollections(t *testing.T) {
|
||||
name: "test with deleteMissing: false",
|
||||
jsonData: `[
|
||||
{
|
||||
"id":"abe78266-fd4d-4aea-962d-8c0138ac522b",
|
||||
"name":"profiles",
|
||||
"system":true,
|
||||
"listRule":"userId = @request.user.id",
|
||||
"viewRule":"created > 'test_change'",
|
||||
"createRule":"userId = @request.user.id",
|
||||
"updateRule":"userId = @request.user.id",
|
||||
"deleteRule":"userId = @request.user.id",
|
||||
"schema":[
|
||||
{
|
||||
"id":"69ycbg3q",
|
||||
"name":"rel",
|
||||
"type":"relation",
|
||||
"system":false,
|
||||
"required":false,
|
||||
"unique":false,
|
||||
"options":{
|
||||
"maxSelect":2,
|
||||
"collectionId":"abe78266-fd4d-4aea-962d-8c0138ac522b",
|
||||
"cascadeDelete":true
|
||||
}
|
||||
},
|
||||
{
|
||||
"id":"abcd_import",
|
||||
"name":"new_field",
|
||||
"type":"bool"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id":"3f2888f8-075d-49fe-9d09-ea7e951000dc",
|
||||
"name":"demo",
|
||||
"id":"wsmn24bux7wo113",
|
||||
"name":"demo1",
|
||||
"schema":[
|
||||
{
|
||||
"id":"_2hlxbmp",
|
||||
@@ -506,14 +501,15 @@ func TestImportCollections(t *testing.T) {
|
||||
]`,
|
||||
deleteMissing: false,
|
||||
expectError: false,
|
||||
expectCollectionsCount: 6,
|
||||
expectCollectionsCount: 9,
|
||||
afterTestFunc: func(testApp *tests.TestApp, resultCollections []*models.Collection) {
|
||||
expectedCollectionFields := map[string]int{
|
||||
"profiles": 6,
|
||||
"demo": 3,
|
||||
"demo2": 14,
|
||||
"demo3": 1,
|
||||
"demo4": 6,
|
||||
"nologin": 1,
|
||||
"demo1": 15,
|
||||
"demo2": 2,
|
||||
"demo3": 2,
|
||||
"demo4": 11,
|
||||
"demo5": 5,
|
||||
"new_import": 1,
|
||||
}
|
||||
for name, expectedCount := range expectedCollectionFields {
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
package daos
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
)
|
||||
|
||||
// ExternalAuthQuery returns a new ExternalAuth select query.
|
||||
func (dao *Dao) ExternalAuthQuery() *dbx.SelectQuery {
|
||||
return dao.ModelQuery(&models.ExternalAuth{})
|
||||
}
|
||||
|
||||
/// FindAllExternalAuthsByRecord returns all ExternalAuth models
|
||||
/// linked to the provided auth record.
|
||||
func (dao *Dao) FindAllExternalAuthsByRecord(authRecord *models.Record) ([]*models.ExternalAuth, error) {
|
||||
auths := []*models.ExternalAuth{}
|
||||
|
||||
err := dao.ExternalAuthQuery().
|
||||
AndWhere(dbx.HashExp{
|
||||
"collectionId": authRecord.Collection().Id,
|
||||
"recordId": authRecord.Id,
|
||||
}).
|
||||
OrderBy("created ASC").
|
||||
All(&auths)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return auths, nil
|
||||
}
|
||||
|
||||
// FindExternalAuthByProvider returns the first available
|
||||
// ExternalAuth model for the specified provider and providerId.
|
||||
func (dao *Dao) FindExternalAuthByProvider(provider, providerId string) (*models.ExternalAuth, error) {
|
||||
model := &models.ExternalAuth{}
|
||||
|
||||
err := dao.ExternalAuthQuery().
|
||||
AndWhere(dbx.Not(dbx.HashExp{"providerId": ""})). // exclude empty providerIds
|
||||
AndWhere(dbx.HashExp{
|
||||
"provider": provider,
|
||||
"providerId": providerId,
|
||||
}).
|
||||
Limit(1).
|
||||
One(model)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return model, nil
|
||||
}
|
||||
|
||||
// FindExternalAuthByRecordAndProvider returns the first available
|
||||
// ExternalAuth model for the specified record data and provider.
|
||||
func (dao *Dao) FindExternalAuthByRecordAndProvider(authRecord *models.Record, provider string) (*models.ExternalAuth, error) {
|
||||
model := &models.ExternalAuth{}
|
||||
|
||||
err := dao.ExternalAuthQuery().
|
||||
AndWhere(dbx.HashExp{
|
||||
"collectionId": authRecord.Collection().Id,
|
||||
"recordId": authRecord.Id,
|
||||
"provider": provider,
|
||||
}).
|
||||
Limit(1).
|
||||
One(model)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return model, nil
|
||||
}
|
||||
|
||||
// SaveExternalAuth upserts the provided ExternalAuth model.
|
||||
func (dao *Dao) SaveExternalAuth(model *models.ExternalAuth) error {
|
||||
// extra check the model data in case the provider's API response
|
||||
// has changed and no longer returns the expected fields
|
||||
if model.CollectionId == "" || model.RecordId == "" || model.Provider == "" || model.ProviderId == "" {
|
||||
return errors.New("Missing required ExternalAuth fields.")
|
||||
}
|
||||
|
||||
return dao.Save(model)
|
||||
}
|
||||
|
||||
// DeleteExternalAuth deletes the provided ExternalAuth model.
|
||||
func (dao *Dao) DeleteExternalAuth(model *models.ExternalAuth) error {
|
||||
return dao.Delete(model)
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
package daos_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
)
|
||||
|
||||
func TestExternalAuthQuery(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
expected := "SELECT {{_externalAuths}}.* FROM `_externalAuths`"
|
||||
|
||||
sql := app.Dao().ExternalAuthQuery().Build().SQL()
|
||||
if sql != expected {
|
||||
t.Errorf("Expected sql %s, got %s", expected, sql)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindAllExternalAuthsByRecord(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
scenarios := []struct {
|
||||
userId string
|
||||
expectedCount int
|
||||
}{
|
||||
{"oap640cot4yru2s", 0},
|
||||
{"4q1xlclmfloku33", 2},
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
record, err := app.Dao().FindRecordById("users", s.userId)
|
||||
if err != nil {
|
||||
t.Errorf("(%d) Unexpected record fetch error %v", i, err)
|
||||
continue
|
||||
}
|
||||
|
||||
auths, err := app.Dao().FindAllExternalAuthsByRecord(record)
|
||||
if err != nil {
|
||||
t.Errorf("(%d) Unexpected auths fetch error %v", i, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if len(auths) != s.expectedCount {
|
||||
t.Errorf("(%d) Expected %d auths, got %d", i, s.expectedCount, len(auths))
|
||||
}
|
||||
|
||||
for _, auth := range auths {
|
||||
if auth.RecordId != record.Id {
|
||||
t.Errorf("(%d) Expected all auths to be linked to record id %s, got %v", i, record.Id, auth)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindExternalAuthByProvider(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
scenarios := []struct {
|
||||
provider string
|
||||
providerId string
|
||||
expectedId string
|
||||
}{
|
||||
{"", "", ""},
|
||||
{"github", "", ""},
|
||||
{"github", "id1", ""},
|
||||
{"github", "id2", ""},
|
||||
{"google", "test123", "clmflokuq1xl341"},
|
||||
{"gitlab", "test123", "dlmflokuq1xl342"},
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
auth, err := app.Dao().FindExternalAuthByProvider(s.provider, s.providerId)
|
||||
|
||||
hasErr := err != nil
|
||||
expectErr := s.expectedId == ""
|
||||
if hasErr != expectErr {
|
||||
t.Errorf("(%d) Expected hasErr %v, got %v", i, expectErr, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if auth != nil && auth.Id != s.expectedId {
|
||||
t.Errorf("(%d) Expected external auth with ID %s, got \n%v", i, s.expectedId, auth)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindExternalAuthByRecordAndProvider(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
scenarios := []struct {
|
||||
userId string
|
||||
provider string
|
||||
expectedId string
|
||||
}{
|
||||
{"bgs820n361vj1qd", "google", ""},
|
||||
{"4q1xlclmfloku33", "google", "clmflokuq1xl341"},
|
||||
{"4q1xlclmfloku33", "gitlab", "dlmflokuq1xl342"},
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
record, err := app.Dao().FindRecordById("users", s.userId)
|
||||
if err != nil {
|
||||
t.Errorf("(%d) Unexpected record fetch error %v", i, err)
|
||||
continue
|
||||
}
|
||||
|
||||
auth, err := app.Dao().FindExternalAuthByRecordAndProvider(record, s.provider)
|
||||
|
||||
hasErr := err != nil
|
||||
expectErr := s.expectedId == ""
|
||||
if hasErr != expectErr {
|
||||
t.Errorf("(%d) Expected hasErr %v, got %v", i, expectErr, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if auth != nil && auth.Id != s.expectedId {
|
||||
t.Errorf("(%d) Expected external auth with ID %s, got \n%v", i, s.expectedId, auth)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveExternalAuth(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
// save with empty provider data
|
||||
emptyAuth := &models.ExternalAuth{}
|
||||
if err := app.Dao().SaveExternalAuth(emptyAuth); err == nil {
|
||||
t.Fatal("Expected error, got nil")
|
||||
}
|
||||
|
||||
auth := &models.ExternalAuth{
|
||||
RecordId: "o1y0dd0spd786md",
|
||||
CollectionId: "v851q4r790rhknl",
|
||||
Provider: "test",
|
||||
ProviderId: "test_id",
|
||||
}
|
||||
|
||||
if err := app.Dao().SaveExternalAuth(auth); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// check if it was really saved
|
||||
foundAuth, err := app.Dao().FindExternalAuthByProvider("test", "test_id")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if auth.Id != foundAuth.Id {
|
||||
t.Fatalf("Expected ExternalAuth with id %s, got \n%v", auth.Id, foundAuth)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteExternalAuth(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
record, err := app.Dao().FindRecordById("users", "4q1xlclmfloku33")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
auths, err := app.Dao().FindAllExternalAuthsByRecord(record)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for _, auth := range auths {
|
||||
if err := app.Dao().DeleteExternalAuth(auth); err != nil {
|
||||
t.Fatalf("Failed to delete the ExternalAuth relation, got \n%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// check if the relations were really deleted
|
||||
newAuths, err := app.Dao().FindAllExternalAuthsByRecord(record)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(newAuths) != 0 {
|
||||
t.Fatalf("Expected all record %s ExternalAuth relations to be deleted, got \n%v", record.Id, newAuths)
|
||||
}
|
||||
}
|
||||
+341
-226
@@ -8,9 +8,11 @@ import (
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/models/schema"
|
||||
"github.com/pocketbase/pocketbase/tools/inflector"
|
||||
"github.com/pocketbase/pocketbase/tools/list"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
"github.com/spf13/cast"
|
||||
)
|
||||
|
||||
// RecordQuery returns a new Record select query.
|
||||
@@ -23,16 +25,24 @@ func (dao *Dao) RecordQuery(collection *models.Collection) *dbx.SelectQuery {
|
||||
|
||||
// FindRecordById finds the Record model by its id.
|
||||
func (dao *Dao) FindRecordById(
|
||||
collection *models.Collection,
|
||||
collectionNameOrId string,
|
||||
recordId string,
|
||||
filter func(q *dbx.SelectQuery) error,
|
||||
optFilters ...func(q *dbx.SelectQuery) error,
|
||||
) (*models.Record, error) {
|
||||
collection, err := dao.FindCollectionByNameOrId(collectionNameOrId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tableName := collection.Name
|
||||
|
||||
query := dao.RecordQuery(collection).
|
||||
AndWhere(dbx.HashExp{tableName + ".id": recordId})
|
||||
|
||||
if filter != nil {
|
||||
for _, filter := range optFilters {
|
||||
if filter == nil {
|
||||
continue
|
||||
}
|
||||
if err := filter(query); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -49,22 +59,31 @@ func (dao *Dao) FindRecordById(
|
||||
// FindRecordsByIds finds all Record models by the provided ids.
|
||||
// If no records are found, returns an empty slice.
|
||||
func (dao *Dao) FindRecordsByIds(
|
||||
collection *models.Collection,
|
||||
collectionNameOrId string,
|
||||
recordIds []string,
|
||||
filter func(q *dbx.SelectQuery) error,
|
||||
optFilters ...func(q *dbx.SelectQuery) error,
|
||||
) ([]*models.Record, error) {
|
||||
tableName := collection.Name
|
||||
collection, err := dao.FindCollectionByNameOrId(collectionNameOrId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
query := dao.RecordQuery(collection).
|
||||
AndWhere(dbx.In(tableName+".id", list.ToInterfaceSlice(recordIds)...))
|
||||
AndWhere(dbx.In(
|
||||
collection.Name+".id",
|
||||
list.ToInterfaceSlice(recordIds)...,
|
||||
))
|
||||
|
||||
if filter != nil {
|
||||
for _, filter := range optFilters {
|
||||
if filter == nil {
|
||||
continue
|
||||
}
|
||||
if err := filter(query); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
rows := []dbx.NullStringMap{}
|
||||
rows := make([]dbx.NullStringMap, 0, len(recordIds))
|
||||
if err := query.All(&rows); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -72,24 +91,35 @@ func (dao *Dao) FindRecordsByIds(
|
||||
return models.NewRecordsFromNullStringMaps(collection, rows), nil
|
||||
}
|
||||
|
||||
// FindRecordsByExpr finds all records by the provided db expression.
|
||||
// If no records are found, returns an empty slice.
|
||||
// FindRecordsByExpr finds all records by the specified db expression.
|
||||
//
|
||||
// Returns all collection records if no expressions are provided.
|
||||
//
|
||||
// Returns an empty slice if no records are found.
|
||||
//
|
||||
// Example:
|
||||
// expr := dbx.HashExp{"email": "test@example.com"}
|
||||
// dao.FindRecordsByExpr(collection, expr)
|
||||
func (dao *Dao) FindRecordsByExpr(collection *models.Collection, expr dbx.Expression) ([]*models.Record, error) {
|
||||
if expr == nil {
|
||||
return nil, errors.New("Missing filter expression")
|
||||
//
|
||||
// expr1 := dbx.HashExp{"email": "test@example.com"}
|
||||
// expr2 := dbx.NewExp("LOWER(username) = {:username}", dbx.Params{"username": "test"})
|
||||
// dao.FindRecordsByExpr("example", expr1, expr2)
|
||||
func (dao *Dao) FindRecordsByExpr(collectionNameOrId string, exprs ...dbx.Expression) ([]*models.Record, error) {
|
||||
collection, err := dao.FindCollectionByNameOrId(collectionNameOrId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
query := dao.RecordQuery(collection)
|
||||
|
||||
// add only the non-nil expressions
|
||||
for _, expr := range exprs {
|
||||
if expr != nil {
|
||||
query.AndWhere(expr)
|
||||
}
|
||||
}
|
||||
|
||||
rows := []dbx.NullStringMap{}
|
||||
|
||||
err := dao.RecordQuery(collection).
|
||||
AndWhere(expr).
|
||||
All(&rows)
|
||||
|
||||
if err != nil {
|
||||
if err := query.All(&rows); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -98,11 +128,16 @@ func (dao *Dao) FindRecordsByExpr(collection *models.Collection, expr dbx.Expres
|
||||
|
||||
// FindFirstRecordByData returns the first found record matching
|
||||
// the provided key-value pair.
|
||||
func (dao *Dao) FindFirstRecordByData(collection *models.Collection, key string, value any) (*models.Record, error) {
|
||||
func (dao *Dao) FindFirstRecordByData(collectionNameOrId string, key string, value any) (*models.Record, error) {
|
||||
collection, err := dao.FindCollectionByNameOrId(collectionNameOrId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
row := dbx.NullStringMap{}
|
||||
|
||||
err := dao.RecordQuery(collection).
|
||||
AndWhere(dbx.HashExp{key: value}).
|
||||
err = dao.RecordQuery(collection).
|
||||
AndWhere(dbx.HashExp{inflector.Columnify(key): value}).
|
||||
Limit(1).
|
||||
One(row)
|
||||
|
||||
@@ -115,263 +150,343 @@ func (dao *Dao) FindFirstRecordByData(collection *models.Collection, key string,
|
||||
|
||||
// IsRecordValueUnique checks if the provided key-value pair is a unique Record value.
|
||||
//
|
||||
// For correctness, if the collection is "auth" and the key is "username",
|
||||
// the unique check will be case insensitive.
|
||||
//
|
||||
// NB! Array values (eg. from multiple select fields) are matched
|
||||
// as a serialized json strings (eg. `["a","b"]`), so the value uniqueness
|
||||
// depends on the elements order. Or in other words the following values
|
||||
// are considered different: `[]string{"a","b"}` and `[]string{"b","a"}`
|
||||
func (dao *Dao) IsRecordValueUnique(
|
||||
collection *models.Collection,
|
||||
collectionNameOrId string,
|
||||
key string,
|
||||
value any,
|
||||
excludeId string,
|
||||
excludeIds ...string,
|
||||
) bool {
|
||||
collection, err := dao.FindCollectionByNameOrId(collectionNameOrId)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
var expr dbx.Expression
|
||||
if collection.IsAuth() && key == schema.FieldNameUsername {
|
||||
expr = dbx.NewExp("LOWER([["+schema.FieldNameUsername+"]])={:username}", dbx.Params{
|
||||
"username": strings.ToLower(cast.ToString(value)),
|
||||
})
|
||||
} else {
|
||||
var normalizedVal any
|
||||
switch val := value.(type) {
|
||||
case []string:
|
||||
normalizedVal = append(types.JsonArray{}, list.ToInterfaceSlice(val)...)
|
||||
case []any:
|
||||
normalizedVal = append(types.JsonArray{}, val...)
|
||||
default:
|
||||
normalizedVal = val
|
||||
}
|
||||
|
||||
expr = dbx.HashExp{inflector.Columnify(key): normalizedVal}
|
||||
}
|
||||
|
||||
query := dao.RecordQuery(collection).
|
||||
Select("count(*)").
|
||||
AndWhere(expr).
|
||||
Limit(1)
|
||||
|
||||
if uniqueExcludeIds := list.NonzeroUniques(excludeIds); len(uniqueExcludeIds) > 0 {
|
||||
query.AndWhere(dbx.NotIn(collection.Name+".id", list.ToInterfaceSlice(uniqueExcludeIds)...))
|
||||
}
|
||||
|
||||
var exists bool
|
||||
|
||||
var normalizedVal any
|
||||
switch val := value.(type) {
|
||||
case []string:
|
||||
normalizedVal = append(types.JsonArray{}, list.ToInterfaceSlice(val)...)
|
||||
case []any:
|
||||
normalizedVal = append(types.JsonArray{}, val...)
|
||||
default:
|
||||
normalizedVal = val
|
||||
}
|
||||
|
||||
err := dao.RecordQuery(collection).
|
||||
Select("count(*)").
|
||||
AndWhere(dbx.Not(dbx.HashExp{"id": excludeId})).
|
||||
AndWhere(dbx.HashExp{key: normalizedVal}).
|
||||
Limit(1).
|
||||
Row(&exists)
|
||||
|
||||
return err == nil && !exists
|
||||
return query.Row(&exists) == nil && !exists
|
||||
}
|
||||
|
||||
// FindUserRelatedRecords returns all records that has a reference
|
||||
// to the provided User model (via the user shema field).
|
||||
func (dao *Dao) FindUserRelatedRecords(user *models.User) ([]*models.Record, error) {
|
||||
if user.Id == "" {
|
||||
return []*models.Record{}, nil
|
||||
}
|
||||
|
||||
collections, err := dao.FindCollectionsWithUserFields()
|
||||
// FindAuthRecordByToken finds the auth record associated with the provided JWT token.
|
||||
//
|
||||
// Returns an error if the JWT token is invalid, expired or not associated to an auth collection record.
|
||||
func (dao *Dao) FindAuthRecordByToken(token string, baseTokenKey string) (*models.Record, error) {
|
||||
unverifiedClaims, err := security.ParseUnverifiedJWT(token)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := []*models.Record{}
|
||||
for _, collection := range collections {
|
||||
userFields := []*schema.SchemaField{}
|
||||
|
||||
// prepare fields options
|
||||
if err := collection.Schema.InitFieldsOptions(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// extract user fields
|
||||
for _, field := range collection.Schema.Fields() {
|
||||
if field.Type == schema.FieldTypeUser {
|
||||
userFields = append(userFields, field)
|
||||
}
|
||||
}
|
||||
|
||||
// fetch records associated to the user
|
||||
exprs := []dbx.Expression{}
|
||||
for _, field := range userFields {
|
||||
exprs = append(exprs, dbx.HashExp{field.Name: user.Id})
|
||||
}
|
||||
rows := []dbx.NullStringMap{}
|
||||
if err := dao.RecordQuery(collection).AndWhere(dbx.Or(exprs...)).All(&rows); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
records := models.NewRecordsFromNullStringMaps(collection, rows)
|
||||
|
||||
result = append(result, records...)
|
||||
// check required claims
|
||||
id, _ := unverifiedClaims["id"].(string)
|
||||
collectionId, _ := unverifiedClaims["collectionId"].(string)
|
||||
if id == "" || collectionId == "" {
|
||||
return nil, errors.New("missing or invalid token claims")
|
||||
}
|
||||
|
||||
return result, nil
|
||||
record, err := dao.FindRecordById(collectionId, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !record.Collection().IsAuth() {
|
||||
return nil, errors.New("The token is not associated to an auth collection record.")
|
||||
}
|
||||
|
||||
verificationKey := record.TokenKey() + baseTokenKey
|
||||
|
||||
// verify token signature
|
||||
if _, err := security.ParseJWT(token, verificationKey); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return record, nil
|
||||
}
|
||||
|
||||
// FindAuthRecordByEmail finds the auth record associated with the provided email.
|
||||
//
|
||||
// Returns an error if it is not an auth collection or the record is not found.
|
||||
func (dao *Dao) FindAuthRecordByEmail(collectionNameOrId string, email string) (*models.Record, error) {
|
||||
collection, err := dao.FindCollectionByNameOrId(collectionNameOrId)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch auth collection %q (%w)", collectionNameOrId, err)
|
||||
}
|
||||
if !collection.IsAuth() {
|
||||
return nil, fmt.Errorf("%q is not an auth collection", collectionNameOrId)
|
||||
}
|
||||
|
||||
row := dbx.NullStringMap{}
|
||||
|
||||
err = dao.RecordQuery(collection).
|
||||
AndWhere(dbx.HashExp{schema.FieldNameEmail: email}).
|
||||
Limit(1).
|
||||
One(row)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return models.NewRecordFromNullStringMap(collection, row), nil
|
||||
}
|
||||
|
||||
// FindAuthRecordByUsername finds the auth record associated with the provided username (case insensitive).
|
||||
//
|
||||
// Returns an error if it is not an auth collection or the record is not found.
|
||||
func (dao *Dao) FindAuthRecordByUsername(collectionNameOrId string, username string) (*models.Record, error) {
|
||||
collection, err := dao.FindCollectionByNameOrId(collectionNameOrId)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch auth collection %q (%w)", collectionNameOrId, err)
|
||||
}
|
||||
if !collection.IsAuth() {
|
||||
return nil, fmt.Errorf("%q is not an auth collection", collectionNameOrId)
|
||||
}
|
||||
|
||||
row := dbx.NullStringMap{}
|
||||
|
||||
err = dao.RecordQuery(collection).
|
||||
AndWhere(dbx.NewExp("LOWER([["+schema.FieldNameUsername+"]])={:username}", dbx.Params{
|
||||
"username": strings.ToLower(username),
|
||||
})).
|
||||
Limit(1).
|
||||
One(row)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return models.NewRecordFromNullStringMap(collection, row), nil
|
||||
}
|
||||
|
||||
// SuggestUniqueAuthRecordUsername checks if the provided username is unique
|
||||
// and return a new "unique" username with appended random numeric part
|
||||
// (eg. "existingName" -> "existingName583").
|
||||
//
|
||||
// The same username will be returned if the provided string is already unique.
|
||||
func (dao *Dao) SuggestUniqueAuthRecordUsername(
|
||||
collectionNameOrId string,
|
||||
baseUsername string,
|
||||
excludeIds ...string,
|
||||
) string {
|
||||
username := baseUsername
|
||||
|
||||
for i := 0; i < 10; i++ { // max 10 attempts
|
||||
isUnique := dao.IsRecordValueUnique(
|
||||
collectionNameOrId,
|
||||
schema.FieldNameUsername,
|
||||
username,
|
||||
excludeIds...,
|
||||
)
|
||||
if isUnique {
|
||||
break // already unique
|
||||
}
|
||||
username = baseUsername + security.RandomStringWithAlphabet(3+i, "123456789")
|
||||
}
|
||||
|
||||
return username
|
||||
}
|
||||
|
||||
// SaveRecord upserts the provided Record model.
|
||||
func (dao *Dao) SaveRecord(record *models.Record) error {
|
||||
if record.Collection().IsAuth() {
|
||||
if record.Username() == "" {
|
||||
return errors.New("unable to save auth record without username")
|
||||
}
|
||||
|
||||
// Cross-check that the auth record id is unique for all auth collections.
|
||||
// This is to make sure that the filter `@request.auth.id` always returns a unique id.
|
||||
authCollections, err := dao.FindCollectionsByType(models.CollectionTypeAuth)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to fetch the auth collections for cross-id unique check: %w", err)
|
||||
}
|
||||
for _, collection := range authCollections {
|
||||
if record.Collection().Id == collection.Id {
|
||||
continue // skip current collection (sqlite will do the check for us)
|
||||
}
|
||||
isUnique := dao.IsRecordValueUnique(collection.Id, schema.FieldNameId, record.Id)
|
||||
if !isUnique {
|
||||
return errors.New("the auth record ID must be unique across all auth collections")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return dao.Save(record)
|
||||
}
|
||||
|
||||
// DeleteRecord deletes the provided Record model.
|
||||
//
|
||||
// This method will also cascade the delete operation to all linked
|
||||
// relational records (delete or set to NULL, depending on the rel settings).
|
||||
// relational records (delete or unset, depending on the rel settings).
|
||||
//
|
||||
// The delete operation may fail if the record is part of a required
|
||||
// reference in another record (aka. cannot be deleted or set to NULL).
|
||||
// reference in another record (aka. cannot be deleted or unset).
|
||||
func (dao *Dao) DeleteRecord(record *models.Record) error {
|
||||
// check for references
|
||||
// note: the select is outside of the transaction to prevent SQLITE_LOCKED error when mixing read&write in a single transaction
|
||||
refs, err := dao.FindCollectionReferences(record.Collection(), "")
|
||||
// fetch rel references (if any)
|
||||
//
|
||||
// note: the select is outside of the transaction to minimize
|
||||
// SQLITE_BUSY errors when mixing read&write in a single transaction
|
||||
refs, err := dao.FindCollectionReferences(record.Collection())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// check if related records has to be deleted (if `CascadeDelete` is set)
|
||||
// OR
|
||||
// just unset the record id from any relation field values (if they are not required)
|
||||
// -----------------------------------------------------------
|
||||
return dao.RunInTransaction(func(txDao *Dao) error {
|
||||
for refCollection, fields := range refs {
|
||||
for _, field := range fields {
|
||||
options, _ := field.Options.(*schema.RelationOptions)
|
||||
|
||||
rows := []dbx.NullStringMap{}
|
||||
|
||||
// note: the select is not using the transaction dao to prevent SQLITE_LOCKED error when mixing read&write in a single transaction
|
||||
err := dao.RecordQuery(refCollection).
|
||||
AndWhere(dbx.Not(dbx.HashExp{"id": record.Id})).
|
||||
AndWhere(dbx.Like(field.Name, record.Id).Match(true, true)).
|
||||
All(&rows)
|
||||
if err != nil {
|
||||
// manually trigger delete on any linked external auth to ensure
|
||||
// that the `OnModel*` hooks are triggered
|
||||
if record.Collection().IsAuth() {
|
||||
// note: the select is outside of the transaction to minimize
|
||||
// SQLITE_BUSY errors when mixing read&write in a single transaction
|
||||
externalAuths, err := dao.FindAllExternalAuthsByRecord(record)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, auth := range externalAuths {
|
||||
if err := txDao.DeleteExternalAuth(auth); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// delete the record before the relation references to ensure that there
|
||||
// will be no "A<->B" relations to prevent deadlock when calling DeleteRecord recursively
|
||||
if err := txDao.Delete(record); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return txDao.cascadeRecordDelete(record, refs)
|
||||
})
|
||||
}
|
||||
|
||||
// cascadeRecordDelete triggers cascade deletion for the provided references.
|
||||
//
|
||||
// NB! This method is expected to be called inside a transaction.
|
||||
func (dao *Dao) cascadeRecordDelete(mainRecord *models.Record, refs map[*models.Collection][]*schema.SchemaField) error {
|
||||
uniqueJsonEachAlias := "__je__" + security.PseudorandomString(4)
|
||||
|
||||
for refCollection, fields := range refs {
|
||||
for _, field := range fields {
|
||||
recordTableName := inflector.Columnify(refCollection.Name)
|
||||
prefixedFieldName := recordTableName + "." + inflector.Columnify(field.Name)
|
||||
|
||||
// @todo optimize single relation lookup in v0.12+
|
||||
query := dao.RecordQuery(refCollection).
|
||||
Distinct(true).
|
||||
InnerJoin(fmt.Sprintf(
|
||||
// note: the case is used to normalize the value access
|
||||
`json_each(CASE WHEN json_valid([[%s]]) THEN [[%s]] ELSE json_array([[%s]]) END) as {{%s}}`,
|
||||
prefixedFieldName, prefixedFieldName, prefixedFieldName, uniqueJsonEachAlias,
|
||||
), dbx.HashExp{uniqueJsonEachAlias + ".value": mainRecord.Id})
|
||||
|
||||
if refCollection.Id == mainRecord.Collection().Id {
|
||||
query.AndWhere(dbx.Not(dbx.HashExp{recordTableName + ".id": mainRecord.Id}))
|
||||
}
|
||||
|
||||
// trigger cascade for each batchSize rel items until there is none
|
||||
batchSize := 4000
|
||||
rows := make([]dbx.NullStringMap, 0, batchSize)
|
||||
for {
|
||||
if err := query.Limit(int64(batchSize)).All(&rows); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
total := len(rows)
|
||||
if total == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
refRecords := models.NewRecordsFromNullStringMaps(refCollection, rows)
|
||||
for _, refRecord := range refRecords {
|
||||
ids := refRecord.GetStringSliceDataValue(field.Name)
|
||||
|
||||
// unset the record id
|
||||
for i := len(ids) - 1; i >= 0; i-- {
|
||||
if ids[i] == record.Id {
|
||||
ids = append(ids[:i], ids[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// cascade delete the reference
|
||||
// (only if there are no other active references in case of multiple select)
|
||||
if options.CascadeDelete && len(ids) == 0 {
|
||||
if err := txDao.DeleteRecord(refRecord); err != nil {
|
||||
return err
|
||||
}
|
||||
// no further action are needed (the reference is deleted)
|
||||
continue
|
||||
}
|
||||
|
||||
if field.Required && len(ids) == 0 {
|
||||
return fmt.Errorf("The record cannot be deleted because it is part of a required reference in record %s (%s collection).", refRecord.Id, refCollection.Name)
|
||||
}
|
||||
|
||||
// save the reference changes
|
||||
refRecord.SetDataValue(field.Name, field.PrepareValue(ids))
|
||||
if err := txDao.SaveRecord(refRecord); err != nil {
|
||||
return err
|
||||
}
|
||||
err := dao.deleteRefRecords(mainRecord, refRecords, field)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if total < batchSize {
|
||||
break // no more items
|
||||
}
|
||||
|
||||
rows = rows[:0] // keep allocated memory
|
||||
}
|
||||
}
|
||||
|
||||
return txDao.Delete(record)
|
||||
})
|
||||
}
|
||||
|
||||
// SyncRecordTableSchema compares the two provided collections
|
||||
// and applies the necessary related record table changes.
|
||||
//
|
||||
// If `oldCollection` is null, then only `newCollection` is used to create the record table.
|
||||
func (dao *Dao) SyncRecordTableSchema(newCollection *models.Collection, oldCollection *models.Collection) error {
|
||||
// create
|
||||
if oldCollection == nil {
|
||||
cols := map[string]string{
|
||||
schema.ReservedFieldNameId: "TEXT PRIMARY KEY",
|
||||
schema.ReservedFieldNameCreated: `TEXT DEFAULT "" NOT NULL`,
|
||||
schema.ReservedFieldNameUpdated: `TEXT DEFAULT "" NOT NULL`,
|
||||
}
|
||||
|
||||
tableName := newCollection.Name
|
||||
|
||||
// add schema field definitions
|
||||
for _, field := range newCollection.Schema.Fields() {
|
||||
cols[field.Name] = field.ColDefinition()
|
||||
}
|
||||
|
||||
// create table
|
||||
_, tableErr := dao.DB().CreateTable(tableName, cols).Execute()
|
||||
if tableErr != nil {
|
||||
return tableErr
|
||||
}
|
||||
|
||||
// add index on the base `created` column
|
||||
_, indexErr := dao.DB().CreateIndex(tableName, tableName+"_created_idx", "created").Execute()
|
||||
if indexErr != nil {
|
||||
return indexErr
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// update
|
||||
return dao.RunInTransaction(func(txDao *Dao) error {
|
||||
oldTableName := oldCollection.Name
|
||||
newTableName := newCollection.Name
|
||||
oldSchema := oldCollection.Schema
|
||||
newSchema := newCollection.Schema
|
||||
|
||||
// check for renamed table
|
||||
if !strings.EqualFold(oldTableName, newTableName) {
|
||||
_, err := dao.DB().RenameTable(oldTableName, newTableName).Execute()
|
||||
if err != nil {
|
||||
return err
|
||||
return nil
|
||||
}
|
||||
|
||||
// deleteRefRecords checks if related records has to be deleted (if `CascadeDelete` is set)
|
||||
// OR
|
||||
// just unset the record id from any relation field values (if they are not required).
|
||||
//
|
||||
// NB! This method is expected to be called inside a transaction.
|
||||
func (dao *Dao) deleteRefRecords(mainRecord *models.Record, refRecords []*models.Record, field *schema.SchemaField) error {
|
||||
options, _ := field.Options.(*schema.RelationOptions)
|
||||
if options == nil {
|
||||
return errors.New("relation field options are not initialized")
|
||||
}
|
||||
|
||||
for _, refRecord := range refRecords {
|
||||
ids := refRecord.GetStringSlice(field.Name)
|
||||
|
||||
// unset the record id
|
||||
for i := len(ids) - 1; i >= 0; i-- {
|
||||
if ids[i] == mainRecord.Id {
|
||||
ids = append(ids[:i], ids[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// check for deleted columns
|
||||
for _, oldField := range oldSchema.Fields() {
|
||||
if f := newSchema.GetFieldById(oldField.Id); f != nil {
|
||||
continue // exist
|
||||
}
|
||||
|
||||
_, err := txDao.DB().DropColumn(newTableName, oldField.Name).Execute()
|
||||
if err != nil {
|
||||
// cascade delete the reference
|
||||
// (only if there are no other active references in case of multiple select)
|
||||
if options.CascadeDelete && len(ids) == 0 {
|
||||
if err := dao.DeleteRecord(refRecord); err != nil {
|
||||
return err
|
||||
}
|
||||
// no further actions are needed (the reference is deleted)
|
||||
continue
|
||||
}
|
||||
|
||||
// check for new or renamed columns
|
||||
toRename := map[string]string{}
|
||||
for _, field := range newSchema.Fields() {
|
||||
oldField := oldSchema.GetFieldById(field.Id)
|
||||
// Note:
|
||||
// We are using a temporary column name when adding or renaming columns
|
||||
// to ensure that there are no name collisions in case there is
|
||||
// names switch/reuse of existing columns (eg. name, title -> title, name).
|
||||
// This way we are always doing 1 more rename operation but it provides better dev experience.
|
||||
|
||||
if oldField == nil {
|
||||
tempName := field.Name + security.RandomString(5)
|
||||
toRename[tempName] = field.Name
|
||||
|
||||
// add
|
||||
_, err := txDao.DB().AddColumn(newTableName, tempName, field.ColDefinition()).Execute()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else if oldField.Name != field.Name {
|
||||
tempName := field.Name + security.RandomString(5)
|
||||
toRename[tempName] = field.Name
|
||||
|
||||
// rename
|
||||
_, err := txDao.DB().RenameColumn(newTableName, oldField.Name, tempName).Execute()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if field.Required && len(ids) == 0 {
|
||||
return fmt.Errorf("the record cannot be deleted because it is part of a required reference in record %s (%s collection)", refRecord.Id, refRecord.Collection().Name)
|
||||
}
|
||||
|
||||
// set the actual columns name
|
||||
for tempName, actualName := range toRename {
|
||||
_, err := txDao.DB().RenameColumn(newTableName, tempName, actualName).Execute()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// save the reference changes
|
||||
refRecord.Set(field.Name, field.PrepareValue(ids))
|
||||
if err := dao.SaveRecord(refRecord); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
+130
-21
@@ -3,11 +3,16 @@ package daos
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/models/schema"
|
||||
"github.com/pocketbase/pocketbase/tools/inflector"
|
||||
"github.com/pocketbase/pocketbase/tools/list"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
)
|
||||
|
||||
// MaxExpandDepth specifies the max allowed nested expand depth path.
|
||||
@@ -40,10 +45,13 @@ func (dao *Dao) ExpandRecords(records []*models.Record, expands []string, fetchF
|
||||
return failed
|
||||
}
|
||||
|
||||
var indirectExpandRegex = regexp.MustCompile(`^(\w+)\((\w+)\)$`)
|
||||
|
||||
// notes:
|
||||
// - fetchFunc must be non-nil func
|
||||
// - all records are expected to be from the same collection
|
||||
// - if MaxExpandDepth is reached, the function returns nil ignoring the remaining expand path
|
||||
// - indirect expands are supported only with single relation fields
|
||||
func (dao *Dao) expandRecords(records []*models.Record, expandPath string, fetchFunc ExpandFetchFunc, recursionLevel int) error {
|
||||
if fetchFunc == nil {
|
||||
return errors.New("Relation records fetchFunc is not set.")
|
||||
@@ -53,29 +61,106 @@ func (dao *Dao) expandRecords(records []*models.Record, expandPath string, fetch
|
||||
return nil
|
||||
}
|
||||
|
||||
parts := strings.SplitN(expandPath, ".", 2)
|
||||
|
||||
// extract the relation field (if exist)
|
||||
mainCollection := records[0].Collection()
|
||||
relField := mainCollection.Schema.GetFieldByName(parts[0])
|
||||
if relField == nil || relField.Type != schema.FieldTypeRelation {
|
||||
return fmt.Errorf("Couldn't find relation field %q in collection %q.", parts[0], mainCollection.Name)
|
||||
}
|
||||
relField.InitOptions()
|
||||
relFieldOptions, ok := relField.Options.(*schema.RelationOptions)
|
||||
if !ok {
|
||||
return fmt.Errorf("Cannot initialize the options of relation field %q.", parts[0])
|
||||
|
||||
var relField *schema.SchemaField
|
||||
var relFieldOptions *schema.RelationOptions
|
||||
var relCollection *models.Collection
|
||||
|
||||
parts := strings.SplitN(expandPath, ".", 2)
|
||||
matches := indirectExpandRegex.FindStringSubmatch(parts[0])
|
||||
|
||||
if len(matches) == 3 {
|
||||
indirectRel, _ := dao.FindCollectionByNameOrId(matches[1])
|
||||
if indirectRel == nil {
|
||||
return fmt.Errorf("Couldn't find indirect related collection %q.", matches[1])
|
||||
}
|
||||
|
||||
indirectRelField := indirectRel.Schema.GetFieldByName(matches[2])
|
||||
if indirectRelField == nil || indirectRelField.Type != schema.FieldTypeRelation {
|
||||
return fmt.Errorf("Couldn't find indirect relation field %q in collection %q.", matches[2], mainCollection.Name)
|
||||
}
|
||||
|
||||
indirectRelField.InitOptions()
|
||||
indirectRelFieldOptions, _ := indirectRelField.Options.(*schema.RelationOptions)
|
||||
if indirectRelFieldOptions == nil || indirectRelFieldOptions.CollectionId != mainCollection.Id {
|
||||
return fmt.Errorf("Invalid indirect relation field path %q.", parts[0])
|
||||
}
|
||||
if indirectRelFieldOptions.MaxSelect != nil && *indirectRelFieldOptions.MaxSelect != 1 {
|
||||
// for now don't allow multi-relation indirect fields expand
|
||||
// due to eventual poor query performance with large data sets.
|
||||
return fmt.Errorf("Multi-relation fields cannot be indirectly expanded in %q.", parts[0])
|
||||
}
|
||||
|
||||
recordIds := make([]any, len(records))
|
||||
for i, record := range records {
|
||||
recordIds[i] = record.Id
|
||||
}
|
||||
|
||||
// @todo after the index optimizations consider allowing
|
||||
// indirect expand for multi-relation fields
|
||||
indirectRecords, err := dao.FindRecordsByExpr(
|
||||
indirectRel.Id,
|
||||
dbx.In(inflector.Columnify(matches[2]), recordIds...),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
mappedIndirectRecordIds := make(map[string][]string, len(indirectRecords))
|
||||
for _, indirectRecord := range indirectRecords {
|
||||
recId := indirectRecord.GetString(matches[2])
|
||||
if recId != "" {
|
||||
mappedIndirectRecordIds[recId] = append(mappedIndirectRecordIds[recId], indirectRecord.Id)
|
||||
}
|
||||
}
|
||||
|
||||
// add the indirect relation ids as a new relation field value
|
||||
for _, record := range records {
|
||||
relIds, ok := mappedIndirectRecordIds[record.Id]
|
||||
if ok && len(relIds) > 0 {
|
||||
record.Set(parts[0], relIds)
|
||||
}
|
||||
}
|
||||
|
||||
relFieldOptions = &schema.RelationOptions{
|
||||
MaxSelect: nil,
|
||||
CollectionId: indirectRel.Id,
|
||||
}
|
||||
if indirectRelField.Unique {
|
||||
relFieldOptions.MaxSelect = types.Pointer(1)
|
||||
}
|
||||
// indirect relation
|
||||
relField = &schema.SchemaField{
|
||||
Id: "indirect_" + security.PseudorandomString(5),
|
||||
Type: schema.FieldTypeRelation,
|
||||
Name: parts[0],
|
||||
Options: relFieldOptions,
|
||||
}
|
||||
relCollection = indirectRel
|
||||
} else {
|
||||
// direct relation
|
||||
relField = mainCollection.Schema.GetFieldByName(parts[0])
|
||||
if relField == nil || relField.Type != schema.FieldTypeRelation {
|
||||
return fmt.Errorf("Couldn't find relation field %q in collection %q.", parts[0], mainCollection.Name)
|
||||
}
|
||||
relField.InitOptions()
|
||||
relFieldOptions, _ = relField.Options.(*schema.RelationOptions)
|
||||
if relFieldOptions == nil {
|
||||
return fmt.Errorf("Couldn't initialize the options of relation field %q.", parts[0])
|
||||
}
|
||||
|
||||
relCollection, _ = dao.FindCollectionByNameOrId(relFieldOptions.CollectionId)
|
||||
if relCollection == nil {
|
||||
return fmt.Errorf("Couldn't find related collection %q.", relFieldOptions.CollectionId)
|
||||
}
|
||||
}
|
||||
|
||||
relCollection, err := dao.FindCollectionByNameOrId(relFieldOptions.CollectionId)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Couldn't find collection %q.", relFieldOptions.CollectionId)
|
||||
}
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
// extract the id of the relations to expand
|
||||
relIds := make([]string, 0, len(records))
|
||||
for _, record := range records {
|
||||
relIds = append(relIds, record.GetStringSliceDataValue(relField.Name)...)
|
||||
relIds = append(relIds, record.GetStringSlice(relField.Name)...)
|
||||
}
|
||||
|
||||
// fetch rels
|
||||
@@ -93,13 +178,13 @@ func (dao *Dao) expandRecords(records []*models.Record, expandPath string, fetch
|
||||
}
|
||||
|
||||
// reindex with the rel id
|
||||
indexedRels := map[string]*models.Record{}
|
||||
indexedRels := make(map[string]*models.Record, len(rels))
|
||||
for _, rel := range rels {
|
||||
indexedRels[rel.GetId()] = rel
|
||||
}
|
||||
|
||||
for _, model := range records {
|
||||
relIds := model.GetStringSliceDataValue(relField.Name)
|
||||
relIds := model.GetStringSlice(relField.Name)
|
||||
|
||||
validRels := make([]*models.Record, 0, len(relIds))
|
||||
for _, id := range relIds {
|
||||
@@ -112,14 +197,38 @@ func (dao *Dao) expandRecords(records []*models.Record, expandPath string, fetch
|
||||
continue // no valid relations
|
||||
}
|
||||
|
||||
expandData := model.GetExpand()
|
||||
expandData := model.Expand()
|
||||
|
||||
// normalize and set the expanded relations
|
||||
if relFieldOptions.MaxSelect == 1 {
|
||||
// normalize access to the previously expanded rel records (if any)
|
||||
var oldExpandedRels []*models.Record
|
||||
switch v := expandData[relField.Name].(type) {
|
||||
case nil:
|
||||
// no old expands
|
||||
case *models.Record:
|
||||
oldExpandedRels = []*models.Record{v}
|
||||
case []*models.Record:
|
||||
oldExpandedRels = v
|
||||
}
|
||||
|
||||
// merge expands
|
||||
for _, oldExpandedRel := range oldExpandedRels {
|
||||
// find a matching rel record
|
||||
for _, rel := range validRels {
|
||||
if rel.Id != oldExpandedRel.Id {
|
||||
continue
|
||||
}
|
||||
|
||||
rel.MergeExpand(oldExpandedRel.Expand())
|
||||
}
|
||||
}
|
||||
|
||||
// update the expanded data
|
||||
if relFieldOptions.MaxSelect != nil && *relFieldOptions.MaxSelect <= 1 {
|
||||
expandData[relField.Name] = validRels[0]
|
||||
} else {
|
||||
expandData[relField.Name] = validRels
|
||||
}
|
||||
|
||||
model.SetExpand(expandData)
|
||||
}
|
||||
|
||||
|
||||
+187
-89
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/models/schema"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
"github.com/pocketbase/pocketbase/tools/list"
|
||||
)
|
||||
@@ -16,140 +17,189 @@ func TestExpandRecords(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
col, _ := app.Dao().FindCollectionByNameOrId("demo4")
|
||||
|
||||
scenarios := []struct {
|
||||
testName string
|
||||
collectionIdOrName string
|
||||
recordIds []string
|
||||
expands []string
|
||||
fetchFunc daos.ExpandFetchFunc
|
||||
expectExpandProps int
|
||||
expectExpandFailures int
|
||||
}{
|
||||
// empty records
|
||||
{
|
||||
"empty records",
|
||||
"",
|
||||
[]string{},
|
||||
[]string{"onerel", "manyrels.onerel.manyrels"},
|
||||
[]string{"self_rel_one", "self_rel_many.self_rel_one"},
|
||||
func(c *models.Collection, ids []string) ([]*models.Record, error) {
|
||||
return app.Dao().FindRecordsByIds(c, ids, nil)
|
||||
return app.Dao().FindRecordsByIds(c.Id, ids, nil)
|
||||
},
|
||||
0,
|
||||
0,
|
||||
},
|
||||
// empty expand
|
||||
{
|
||||
[]string{"b8ba58f9-e2d7-42a0-b0e7-a11efd98236b", "df55c8ff-45ef-4c82-8aed-6e2183fe1125"},
|
||||
"empty expand",
|
||||
"demo4",
|
||||
[]string{"i9naidtvr6qsgb4", "qzaqccwrmva4o1n"},
|
||||
[]string{},
|
||||
func(c *models.Collection, ids []string) ([]*models.Record, error) {
|
||||
return app.Dao().FindRecordsByIds(c, ids, nil)
|
||||
return app.Dao().FindRecordsByIds(c.Id, ids, nil)
|
||||
},
|
||||
0,
|
||||
0,
|
||||
},
|
||||
// empty fetchFunc
|
||||
{
|
||||
[]string{"b8ba58f9-e2d7-42a0-b0e7-a11efd98236b", "df55c8ff-45ef-4c82-8aed-6e2183fe1125"},
|
||||
[]string{"onerel", "manyrels.onerel.manyrels"},
|
||||
"empty fetchFunc",
|
||||
"demo4",
|
||||
[]string{"i9naidtvr6qsgb4", "qzaqccwrmva4o1n"},
|
||||
[]string{"self_rel_one", "self_rel_many.self_rel_one"},
|
||||
nil,
|
||||
0,
|
||||
2,
|
||||
},
|
||||
// fetchFunc with error
|
||||
{
|
||||
[]string{"b8ba58f9-e2d7-42a0-b0e7-a11efd98236b", "df55c8ff-45ef-4c82-8aed-6e2183fe1125"},
|
||||
[]string{"onerel", "manyrels.onerel.manyrels"},
|
||||
"fetchFunc with error",
|
||||
"demo4",
|
||||
[]string{"i9naidtvr6qsgb4", "qzaqccwrmva4o1n"},
|
||||
[]string{"self_rel_one", "self_rel_many.self_rel_one"},
|
||||
func(c *models.Collection, ids []string) ([]*models.Record, error) {
|
||||
return nil, errors.New("test error")
|
||||
},
|
||||
0,
|
||||
2,
|
||||
},
|
||||
// missing relation field
|
||||
{
|
||||
[]string{"b8ba58f9-e2d7-42a0-b0e7-a11efd98236b", "df55c8ff-45ef-4c82-8aed-6e2183fe1125"},
|
||||
[]string{"invalid"},
|
||||
"missing relation field",
|
||||
"demo4",
|
||||
[]string{"i9naidtvr6qsgb4", "qzaqccwrmva4o1n"},
|
||||
[]string{"missing"},
|
||||
func(c *models.Collection, ids []string) ([]*models.Record, error) {
|
||||
return app.Dao().FindRecordsByIds(c, ids, nil)
|
||||
return app.Dao().FindRecordsByIds(c.Id, ids, nil)
|
||||
},
|
||||
0,
|
||||
1,
|
||||
},
|
||||
// existing, but non-relation type field
|
||||
{
|
||||
[]string{"b8ba58f9-e2d7-42a0-b0e7-a11efd98236b", "df55c8ff-45ef-4c82-8aed-6e2183fe1125"},
|
||||
"existing, but non-relation type field",
|
||||
"demo4",
|
||||
[]string{"i9naidtvr6qsgb4", "qzaqccwrmva4o1n"},
|
||||
[]string{"title"},
|
||||
func(c *models.Collection, ids []string) ([]*models.Record, error) {
|
||||
return app.Dao().FindRecordsByIds(c, ids, nil)
|
||||
return app.Dao().FindRecordsByIds(c.Id, ids, nil)
|
||||
},
|
||||
0,
|
||||
1,
|
||||
},
|
||||
// invalid/missing second level expand
|
||||
{
|
||||
[]string{"b8ba58f9-e2d7-42a0-b0e7-a11efd98236b", "df55c8ff-45ef-4c82-8aed-6e2183fe1125"},
|
||||
[]string{"manyrels.invalid"},
|
||||
"invalid/missing second level expand",
|
||||
"demo4",
|
||||
[]string{"i9naidtvr6qsgb4", "qzaqccwrmva4o1n"},
|
||||
[]string{"rel_one_no_cascade.title"},
|
||||
func(c *models.Collection, ids []string) ([]*models.Record, error) {
|
||||
return app.Dao().FindRecordsByIds(c, ids, nil)
|
||||
return app.Dao().FindRecordsByIds(c.Id, ids, nil)
|
||||
},
|
||||
0,
|
||||
1,
|
||||
},
|
||||
// expand normalizations
|
||||
{
|
||||
"expand normalizations",
|
||||
"demo4",
|
||||
[]string{"i9naidtvr6qsgb4", "qzaqccwrmva4o1n"},
|
||||
[]string{
|
||||
"b8ba58f9-e2d7-42a0-b0e7-a11efd98236b",
|
||||
"df55c8ff-45ef-4c82-8aed-6e2183fe1125",
|
||||
"b84cd893-7119-43c9-8505-3c4e22da28a9",
|
||||
"054f9f24-0a0a-4e09-87b1-bc7ff2b336a2",
|
||||
"self_rel_one", "self_rel_many.self_rel_many.rel_one_no_cascade",
|
||||
"self_rel_many.self_rel_one.self_rel_many.self_rel_one.rel_one_no_cascade",
|
||||
"self_rel_many", "self_rel_many.",
|
||||
" self_rel_many ", "",
|
||||
},
|
||||
[]string{"manyrels.onerel.manyrels.onerel", "manyrels.onerel", "onerel", "onerel.", " onerel ", ""},
|
||||
func(c *models.Collection, ids []string) ([]*models.Record, error) {
|
||||
return app.Dao().FindRecordsByIds(c, ids, nil)
|
||||
return app.Dao().FindRecordsByIds(c.Id, ids, nil)
|
||||
},
|
||||
9,
|
||||
0,
|
||||
},
|
||||
// single expand
|
||||
{
|
||||
"single expand",
|
||||
"users",
|
||||
[]string{
|
||||
"b8ba58f9-e2d7-42a0-b0e7-a11efd98236b",
|
||||
"df55c8ff-45ef-4c82-8aed-6e2183fe1125",
|
||||
"b84cd893-7119-43c9-8505-3c4e22da28a9", // no manyrels
|
||||
"054f9f24-0a0a-4e09-87b1-bc7ff2b336a2", // no manyrels
|
||||
"bgs820n361vj1qd",
|
||||
"4q1xlclmfloku33",
|
||||
"oap640cot4yru2s", // no rels
|
||||
},
|
||||
[]string{"manyrels"},
|
||||
[]string{"rel"},
|
||||
func(c *models.Collection, ids []string) ([]*models.Record, error) {
|
||||
return app.Dao().FindRecordsByIds(c, ids, nil)
|
||||
return app.Dao().FindRecordsByIds(c.Id, ids, nil)
|
||||
},
|
||||
2,
|
||||
0,
|
||||
},
|
||||
// maxExpandDepth reached
|
||||
{
|
||||
[]string{"b8ba58f9-e2d7-42a0-b0e7-a11efd98236b"},
|
||||
[]string{"manyrels.onerel.manyrels.onerel.manyrels.onerel.manyrels.onerel.manyrels"},
|
||||
"maxExpandDepth reached",
|
||||
"demo4",
|
||||
[]string{"qzaqccwrmva4o1n"},
|
||||
[]string{"self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many"},
|
||||
func(c *models.Collection, ids []string) ([]*models.Record, error) {
|
||||
return app.Dao().FindRecordsByIds(c, ids, nil)
|
||||
return app.Dao().FindRecordsByIds(c.Id, ids, nil)
|
||||
},
|
||||
6,
|
||||
0,
|
||||
},
|
||||
{
|
||||
"simple indirect expand",
|
||||
"demo3",
|
||||
[]string{"lcl9d87w22ml6jy"},
|
||||
[]string{"demo4(rel_one_no_cascade_required)"},
|
||||
func(c *models.Collection, ids []string) ([]*models.Record, error) {
|
||||
return app.Dao().FindRecordsByIds(c.Id, ids, nil)
|
||||
},
|
||||
1,
|
||||
0,
|
||||
},
|
||||
{
|
||||
"nested indirect expand",
|
||||
"demo3",
|
||||
[]string{"lcl9d87w22ml6jy"},
|
||||
[]string{
|
||||
"demo4(rel_one_no_cascade_required).self_rel_many.self_rel_many.self_rel_one",
|
||||
},
|
||||
func(c *models.Collection, ids []string) ([]*models.Record, error) {
|
||||
return app.Dao().FindRecordsByIds(c.Id, ids, nil)
|
||||
},
|
||||
5,
|
||||
0,
|
||||
},
|
||||
{
|
||||
"expand multiple relations sharing a common path",
|
||||
"demo4",
|
||||
[]string{"qzaqccwrmva4o1n"},
|
||||
[]string{
|
||||
"rel_one_no_cascade",
|
||||
"rel_many_no_cascade",
|
||||
"self_rel_many.self_rel_one.rel_many_cascade",
|
||||
"self_rel_many.self_rel_one.rel_many_no_cascade_required",
|
||||
},
|
||||
func(c *models.Collection, ids []string) ([]*models.Record, error) {
|
||||
return app.Dao().FindRecordsByIds(c.Id, ids, nil)
|
||||
},
|
||||
5,
|
||||
0,
|
||||
},
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
for _, s := range scenarios {
|
||||
ids := list.ToUniqueStringSlice(s.recordIds)
|
||||
records, _ := app.Dao().FindRecordsByIds(col, ids, nil)
|
||||
records, _ := app.Dao().FindRecordsByIds(s.collectionIdOrName, ids)
|
||||
failed := app.Dao().ExpandRecords(records, s.expands, s.fetchFunc)
|
||||
|
||||
if len(failed) != s.expectExpandFailures {
|
||||
t.Errorf("(%d) Expected %d failures, got %d: \n%v", i, s.expectExpandFailures, len(failed), failed)
|
||||
t.Errorf("[%s] Expected %d failures, got %d: \n%v", s.testName, s.expectExpandFailures, len(failed), failed)
|
||||
}
|
||||
|
||||
encoded, _ := json.Marshal(records)
|
||||
encodedStr := string(encoded)
|
||||
totalExpandProps := strings.Count(encodedStr, "@expand")
|
||||
totalExpandProps := strings.Count(encodedStr, schema.FieldNameExpand)
|
||||
|
||||
if s.expectExpandProps != totalExpandProps {
|
||||
t.Errorf("(%d) Expected %d @expand props, got %d: \n%v", i, s.expectExpandProps, totalExpandProps, encodedStr)
|
||||
t.Errorf("[%s] Expected %d expand props, got %d: \n%v", s.testName, s.expectExpandProps, totalExpandProps, encodedStr)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -158,109 +208,157 @@ func TestExpandRecord(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
col, _ := app.Dao().FindCollectionByNameOrId("demo4")
|
||||
|
||||
scenarios := []struct {
|
||||
testName string
|
||||
collectionIdOrName string
|
||||
recordId string
|
||||
expands []string
|
||||
fetchFunc daos.ExpandFetchFunc
|
||||
expectExpandProps int
|
||||
expectExpandFailures int
|
||||
}{
|
||||
// empty expand
|
||||
{
|
||||
"b8ba58f9-e2d7-42a0-b0e7-a11efd98236b",
|
||||
"empty expand",
|
||||
"demo4",
|
||||
"i9naidtvr6qsgb4",
|
||||
[]string{},
|
||||
func(c *models.Collection, ids []string) ([]*models.Record, error) {
|
||||
return app.Dao().FindRecordsByIds(c, ids, nil)
|
||||
return app.Dao().FindRecordsByIds(c.Id, ids, nil)
|
||||
},
|
||||
0,
|
||||
0,
|
||||
},
|
||||
// empty fetchFunc
|
||||
{
|
||||
"b8ba58f9-e2d7-42a0-b0e7-a11efd98236b",
|
||||
[]string{"onerel", "manyrels.onerel.manyrels"},
|
||||
"empty fetchFunc",
|
||||
"demo4",
|
||||
"i9naidtvr6qsgb4",
|
||||
[]string{"self_rel_one", "self_rel_many.self_rel_one"},
|
||||
nil,
|
||||
0,
|
||||
2,
|
||||
},
|
||||
// fetchFunc with error
|
||||
{
|
||||
"b8ba58f9-e2d7-42a0-b0e7-a11efd98236b",
|
||||
[]string{"onerel", "manyrels.onerel.manyrels"},
|
||||
"fetchFunc with error",
|
||||
"demo4",
|
||||
"i9naidtvr6qsgb4",
|
||||
[]string{"self_rel_one", "self_rel_many.self_rel_one"},
|
||||
func(c *models.Collection, ids []string) ([]*models.Record, error) {
|
||||
return nil, errors.New("test error")
|
||||
},
|
||||
0,
|
||||
2,
|
||||
},
|
||||
// invalid missing first level expand
|
||||
{
|
||||
"b8ba58f9-e2d7-42a0-b0e7-a11efd98236b",
|
||||
[]string{"invalid"},
|
||||
"missing relation field",
|
||||
"demo4",
|
||||
"i9naidtvr6qsgb4",
|
||||
[]string{"missing"},
|
||||
func(c *models.Collection, ids []string) ([]*models.Record, error) {
|
||||
return app.Dao().FindRecordsByIds(c, ids, nil)
|
||||
return app.Dao().FindRecordsByIds(c.Id, ids, nil)
|
||||
},
|
||||
0,
|
||||
1,
|
||||
},
|
||||
// invalid missing second level expand
|
||||
{
|
||||
"b8ba58f9-e2d7-42a0-b0e7-a11efd98236b",
|
||||
[]string{"manyrels.invalid"},
|
||||
"existing, but non-relation type field",
|
||||
"demo4",
|
||||
"i9naidtvr6qsgb4",
|
||||
[]string{"title"},
|
||||
func(c *models.Collection, ids []string) ([]*models.Record, error) {
|
||||
return app.Dao().FindRecordsByIds(c, ids, nil)
|
||||
return app.Dao().FindRecordsByIds(c.Id, ids, nil)
|
||||
},
|
||||
0,
|
||||
1,
|
||||
},
|
||||
// expand normalizations
|
||||
{
|
||||
"b8ba58f9-e2d7-42a0-b0e7-a11efd98236b",
|
||||
[]string{"manyrels.onerel.manyrels", "manyrels.onerel", "onerel", " onerel "},
|
||||
"invalid/missing second level expand",
|
||||
"demo4",
|
||||
"qzaqccwrmva4o1n",
|
||||
[]string{"rel_one_no_cascade.title"},
|
||||
func(c *models.Collection, ids []string) ([]*models.Record, error) {
|
||||
return app.Dao().FindRecordsByIds(c, ids, nil)
|
||||
return app.Dao().FindRecordsByIds(c.Id, ids, nil)
|
||||
},
|
||||
3,
|
||||
0,
|
||||
},
|
||||
// single expand
|
||||
{
|
||||
"b8ba58f9-e2d7-42a0-b0e7-a11efd98236b",
|
||||
[]string{"manyrels"},
|
||||
func(c *models.Collection, ids []string) ([]*models.Record, error) {
|
||||
return app.Dao().FindRecordsByIds(c, ids, nil)
|
||||
},
|
||||
1,
|
||||
},
|
||||
{
|
||||
"expand normalizations",
|
||||
"demo4",
|
||||
"qzaqccwrmva4o1n",
|
||||
[]string{
|
||||
"self_rel_one", "self_rel_many.self_rel_many.rel_one_no_cascade",
|
||||
"self_rel_many.self_rel_one.self_rel_many.self_rel_one.rel_one_no_cascade",
|
||||
"self_rel_many", "self_rel_many.",
|
||||
" self_rel_many ", "",
|
||||
},
|
||||
func(c *models.Collection, ids []string) ([]*models.Record, error) {
|
||||
return app.Dao().FindRecordsByIds(c.Id, ids, nil)
|
||||
},
|
||||
8,
|
||||
0,
|
||||
},
|
||||
// maxExpandDepth reached
|
||||
{
|
||||
"b8ba58f9-e2d7-42a0-b0e7-a11efd98236b",
|
||||
[]string{"manyrels.onerel.manyrels.onerel.manyrels.onerel.manyrels.onerel.manyrels"},
|
||||
"no rels to expand",
|
||||
"users",
|
||||
"oap640cot4yru2s",
|
||||
[]string{"rel"},
|
||||
func(c *models.Collection, ids []string) ([]*models.Record, error) {
|
||||
return app.Dao().FindRecordsByIds(c, ids, nil)
|
||||
return app.Dao().FindRecordsByIds(c.Id, ids, nil)
|
||||
},
|
||||
0,
|
||||
0,
|
||||
},
|
||||
{
|
||||
"maxExpandDepth reached",
|
||||
"demo4",
|
||||
"qzaqccwrmva4o1n",
|
||||
[]string{"self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many"},
|
||||
func(c *models.Collection, ids []string) ([]*models.Record, error) {
|
||||
return app.Dao().FindRecordsByIds(c.Id, ids, nil)
|
||||
},
|
||||
6,
|
||||
0,
|
||||
},
|
||||
{
|
||||
"simple indirect expand",
|
||||
"demo3",
|
||||
"lcl9d87w22ml6jy",
|
||||
[]string{"demo4(rel_one_no_cascade_required)"},
|
||||
func(c *models.Collection, ids []string) ([]*models.Record, error) {
|
||||
return app.Dao().FindRecordsByIds(c.Id, ids, nil)
|
||||
},
|
||||
1,
|
||||
0,
|
||||
},
|
||||
{
|
||||
"nested indirect expand",
|
||||
"demo3",
|
||||
"lcl9d87w22ml6jy",
|
||||
[]string{
|
||||
"demo4(rel_one_no_cascade_required).self_rel_many.self_rel_many.self_rel_one",
|
||||
},
|
||||
func(c *models.Collection, ids []string) ([]*models.Record, error) {
|
||||
return app.Dao().FindRecordsByIds(c.Id, ids, nil)
|
||||
},
|
||||
5,
|
||||
0,
|
||||
},
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
record, _ := app.Dao().FindFirstRecordByData(col, "id", s.recordId)
|
||||
for _, s := range scenarios {
|
||||
record, _ := app.Dao().FindRecordById(s.collectionIdOrName, s.recordId)
|
||||
failed := app.Dao().ExpandRecord(record, s.expands, s.fetchFunc)
|
||||
|
||||
if len(failed) != s.expectExpandFailures {
|
||||
t.Errorf("(%d) Expected %d failures, got %d: \n%v", i, s.expectExpandFailures, len(failed), failed)
|
||||
t.Errorf("[%s] Expected %d failures, got %d: \n%v", s.testName, s.expectExpandFailures, len(failed), failed)
|
||||
}
|
||||
|
||||
encoded, _ := json.Marshal(record)
|
||||
encodedStr := string(encoded)
|
||||
totalExpandProps := strings.Count(encodedStr, "@expand")
|
||||
totalExpandProps := strings.Count(encodedStr, schema.FieldNameExpand)
|
||||
|
||||
if s.expectExpandProps != totalExpandProps {
|
||||
t.Errorf("(%d) Expected %d @expand props, got %d: \n%v", i, s.expectExpandProps, totalExpandProps, encodedStr)
|
||||
t.Errorf("[%s] Expected %d expand props, got %d: \n%v", s.testName, s.expectExpandProps, totalExpandProps, encodedStr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
package daos
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/models/schema"
|
||||
"github.com/pocketbase/pocketbase/tools/list"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
)
|
||||
|
||||
// SyncRecordTableSchema compares the two provided collections
|
||||
// and applies the necessary related record table changes.
|
||||
//
|
||||
// If `oldCollection` is null, then only `newCollection` is used to create the record table.
|
||||
func (dao *Dao) SyncRecordTableSchema(newCollection *models.Collection, oldCollection *models.Collection) error {
|
||||
// create
|
||||
if oldCollection == nil {
|
||||
cols := map[string]string{
|
||||
schema.FieldNameId: "TEXT PRIMARY KEY NOT NULL",
|
||||
schema.FieldNameCreated: "TEXT DEFAULT '' NOT NULL",
|
||||
schema.FieldNameUpdated: "TEXT DEFAULT '' NOT NULL",
|
||||
}
|
||||
|
||||
if newCollection.IsAuth() {
|
||||
cols[schema.FieldNameUsername] = "TEXT NOT NULL"
|
||||
cols[schema.FieldNameEmail] = "TEXT DEFAULT '' NOT NULL"
|
||||
cols[schema.FieldNameEmailVisibility] = "BOOLEAN DEFAULT FALSE NOT NULL"
|
||||
cols[schema.FieldNameVerified] = "BOOLEAN DEFAULT FALSE NOT NULL"
|
||||
cols[schema.FieldNameTokenKey] = "TEXT NOT NULL"
|
||||
cols[schema.FieldNamePasswordHash] = "TEXT NOT NULL"
|
||||
cols[schema.FieldNameLastResetSentAt] = "TEXT DEFAULT '' NOT NULL"
|
||||
cols[schema.FieldNameLastVerificationSentAt] = "TEXT DEFAULT '' NOT NULL"
|
||||
}
|
||||
|
||||
// ensure that the new collection has an id
|
||||
if !newCollection.HasId() {
|
||||
newCollection.RefreshId()
|
||||
newCollection.MarkAsNew()
|
||||
}
|
||||
|
||||
tableName := newCollection.Name
|
||||
|
||||
// add schema field definitions
|
||||
for _, field := range newCollection.Schema.Fields() {
|
||||
cols[field.Name] = field.ColDefinition()
|
||||
}
|
||||
|
||||
// create table
|
||||
if _, err := dao.DB().CreateTable(tableName, cols).Execute(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// add named index on the base `created` column
|
||||
if _, err := dao.DB().CreateIndex(tableName, "_"+newCollection.Id+"_created_idx", "created").Execute(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// add named unique index on the email and tokenKey columns
|
||||
if newCollection.IsAuth() {
|
||||
_, err := dao.DB().NewQuery(fmt.Sprintf(
|
||||
`
|
||||
CREATE UNIQUE INDEX _%s_username_idx ON {{%s}} ([[username]]);
|
||||
CREATE UNIQUE INDEX _%s_email_idx ON {{%s}} ([[email]]) WHERE [[email]] != '';
|
||||
CREATE UNIQUE INDEX _%s_tokenKey_idx ON {{%s}} ([[tokenKey]]);
|
||||
`,
|
||||
newCollection.Id, tableName,
|
||||
newCollection.Id, tableName,
|
||||
newCollection.Id, tableName,
|
||||
)).Execute()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// update
|
||||
return dao.RunInTransaction(func(txDao *Dao) error {
|
||||
oldTableName := oldCollection.Name
|
||||
newTableName := newCollection.Name
|
||||
oldSchema := oldCollection.Schema
|
||||
newSchema := newCollection.Schema
|
||||
deletedFieldNames := []string{}
|
||||
renamedFieldNames := map[string]string{}
|
||||
|
||||
// check for renamed table
|
||||
if !strings.EqualFold(oldTableName, newTableName) {
|
||||
_, err := txDao.DB().RenameTable(oldTableName, newTableName).Execute()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// check for deleted columns
|
||||
for _, oldField := range oldSchema.Fields() {
|
||||
if f := newSchema.GetFieldById(oldField.Id); f != nil {
|
||||
continue // exist
|
||||
}
|
||||
|
||||
_, err := txDao.DB().DropColumn(newTableName, oldField.Name).Execute()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
deletedFieldNames = append(deletedFieldNames, oldField.Name)
|
||||
}
|
||||
|
||||
// check for new or renamed columns
|
||||
toRename := map[string]string{}
|
||||
for _, field := range newSchema.Fields() {
|
||||
oldField := oldSchema.GetFieldById(field.Id)
|
||||
// Note:
|
||||
// We are using a temporary column name when adding or renaming columns
|
||||
// to ensure that there are no name collisions in case there is
|
||||
// names switch/reuse of existing columns (eg. name, title -> title, name).
|
||||
// This way we are always doing 1 more rename operation but it provides better dev experience.
|
||||
|
||||
if oldField == nil {
|
||||
tempName := field.Name + security.PseudorandomString(5)
|
||||
toRename[tempName] = field.Name
|
||||
|
||||
// add
|
||||
_, err := txDao.DB().AddColumn(newTableName, tempName, field.ColDefinition()).Execute()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else if oldField.Name != field.Name {
|
||||
tempName := field.Name + security.PseudorandomString(5)
|
||||
toRename[tempName] = field.Name
|
||||
|
||||
// rename
|
||||
_, err := txDao.DB().RenameColumn(newTableName, oldField.Name, tempName).Execute()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
renamedFieldNames[oldField.Name] = field.Name
|
||||
}
|
||||
}
|
||||
|
||||
// set the actual columns name
|
||||
for tempName, actualName := range toRename {
|
||||
_, err := txDao.DB().RenameColumn(newTableName, tempName, actualName).Execute()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return txDao.syncCollectionReferences(newCollection, renamedFieldNames, deletedFieldNames)
|
||||
})
|
||||
}
|
||||
|
||||
func (dao *Dao) syncCollectionReferences(collection *models.Collection, renamedFieldNames map[string]string, deletedFieldNames []string) error {
|
||||
if len(renamedFieldNames) == 0 && len(deletedFieldNames) == 0 {
|
||||
return nil // nothing to sync
|
||||
}
|
||||
|
||||
refs, err := dao.FindCollectionReferences(collection)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for refCollection, refFields := range refs {
|
||||
for _, refField := range refFields {
|
||||
options, _ := refField.Options.(*schema.RelationOptions)
|
||||
if options == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// remove deleted (if any)
|
||||
newDisplayFields := list.SubtractSlice(options.DisplayFields, deletedFieldNames)
|
||||
|
||||
for old, new := range renamedFieldNames {
|
||||
for i, name := range newDisplayFields {
|
||||
if name == old {
|
||||
newDisplayFields[i] = new
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// has changes
|
||||
if len(list.SubtractSlice(options.DisplayFields, newDisplayFields)) > 0 {
|
||||
options.DisplayFields = newDisplayFields
|
||||
|
||||
// direct collection save to prevent self-referencing
|
||||
// recursion and unnecessary records table sync checks
|
||||
if err := dao.Save(refCollection); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package daos_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/models/schema"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
"github.com/pocketbase/pocketbase/tools/list"
|
||||
)
|
||||
|
||||
func TestSyncRecordTableSchema(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
oldCollection, err := app.Dao().FindCollectionByNameOrId("demo2")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
updatedCollection, err := app.Dao().FindCollectionByNameOrId("demo2")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
updatedCollection.Name = "demo_renamed"
|
||||
updatedCollection.Schema.RemoveField(updatedCollection.Schema.GetFieldByName("active").Id)
|
||||
updatedCollection.Schema.AddField(
|
||||
&schema.SchemaField{
|
||||
Name: "new_field",
|
||||
Type: schema.FieldTypeEmail,
|
||||
},
|
||||
)
|
||||
updatedCollection.Schema.AddField(
|
||||
&schema.SchemaField{
|
||||
Id: updatedCollection.Schema.GetFieldByName("title").Id,
|
||||
Name: "title_renamed",
|
||||
Type: schema.FieldTypeEmail,
|
||||
},
|
||||
)
|
||||
|
||||
scenarios := []struct {
|
||||
newCollection *models.Collection
|
||||
oldCollection *models.Collection
|
||||
expectedTableName string
|
||||
expectedColumns []string
|
||||
}{
|
||||
// new base collection
|
||||
{
|
||||
&models.Collection{
|
||||
Name: "new_table",
|
||||
Schema: schema.NewSchema(
|
||||
&schema.SchemaField{
|
||||
Name: "test",
|
||||
Type: schema.FieldTypeText,
|
||||
},
|
||||
),
|
||||
},
|
||||
nil,
|
||||
"new_table",
|
||||
[]string{"id", "created", "updated", "test"},
|
||||
},
|
||||
// new auth collection
|
||||
{
|
||||
&models.Collection{
|
||||
Name: "new_table_auth",
|
||||
Type: models.CollectionTypeAuth,
|
||||
Schema: schema.NewSchema(
|
||||
&schema.SchemaField{
|
||||
Name: "test",
|
||||
Type: schema.FieldTypeText,
|
||||
},
|
||||
),
|
||||
},
|
||||
nil,
|
||||
"new_table_auth",
|
||||
[]string{
|
||||
"id", "created", "updated", "test",
|
||||
"username", "email", "verified", "emailVisibility",
|
||||
"tokenKey", "passwordHash", "lastResetSentAt", "lastVerificationSentAt",
|
||||
},
|
||||
},
|
||||
// no changes
|
||||
{
|
||||
oldCollection,
|
||||
oldCollection,
|
||||
"demo3",
|
||||
[]string{"id", "created", "updated", "title", "active"},
|
||||
},
|
||||
// renamed table, deleted column, renamed columnd and new column
|
||||
{
|
||||
updatedCollection,
|
||||
oldCollection,
|
||||
"demo_renamed",
|
||||
[]string{"id", "created", "updated", "title_renamed", "new_field"},
|
||||
},
|
||||
}
|
||||
|
||||
for i, scenario := range scenarios {
|
||||
err := app.Dao().SyncRecordTableSchema(scenario.newCollection, scenario.oldCollection)
|
||||
if err != nil {
|
||||
t.Errorf("(%d) %v", i, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if !app.Dao().HasTable(scenario.newCollection.Name) {
|
||||
t.Errorf("(%d) Expected table %s to exist", i, scenario.newCollection.Name)
|
||||
}
|
||||
|
||||
cols, _ := app.Dao().GetTableColumns(scenario.newCollection.Name)
|
||||
if len(cols) != len(scenario.expectedColumns) {
|
||||
t.Errorf("(%d) Expected columns %v, got %v", i, scenario.expectedColumns, cols)
|
||||
}
|
||||
|
||||
for _, c := range cols {
|
||||
if !list.ExistInSlice(c, scenario.expectedColumns) {
|
||||
t.Errorf("(%d) Couldn't find column %s in %v", i, c, scenario.expectedColumns)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+543
-195
@@ -1,22 +1,32 @@
|
||||
package daos_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/models/schema"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
"github.com/pocketbase/pocketbase/tools/list"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
)
|
||||
|
||||
func TestRecordQuery(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
collection, _ := app.Dao().FindCollectionByNameOrId("demo")
|
||||
collection, err := app.Dao().FindCollectionByNameOrId("demo1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
expected := fmt.Sprintf("SELECT `%s`.* FROM `%s`", collection.Name, collection.Name)
|
||||
|
||||
@@ -30,30 +40,50 @@ func TestFindRecordById(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
collection, _ := app.Dao().FindCollectionByNameOrId("demo")
|
||||
|
||||
scenarios := []struct {
|
||||
id string
|
||||
filter func(q *dbx.SelectQuery) error
|
||||
expectError bool
|
||||
collectionIdOrName string
|
||||
id string
|
||||
filter1 func(q *dbx.SelectQuery) error
|
||||
filter2 func(q *dbx.SelectQuery) error
|
||||
expectError bool
|
||||
}{
|
||||
{"00000000-bafd-48f7-b8b7-090638afe209", nil, true},
|
||||
{"b5c2ffc2-bafd-48f7-b8b7-090638afe209", nil, false},
|
||||
{"b5c2ffc2-bafd-48f7-b8b7-090638afe209", func(q *dbx.SelectQuery) error {
|
||||
{"demo2", "missing", nil, nil, true},
|
||||
{"missing", "0yxhwia2amd8gec", nil, nil, true},
|
||||
{"demo2", "0yxhwia2amd8gec", nil, nil, false},
|
||||
{"demo2", "0yxhwia2amd8gec", func(q *dbx.SelectQuery) error {
|
||||
q.AndWhere(dbx.HashExp{"title": "missing"})
|
||||
return nil
|
||||
}, true},
|
||||
{"b5c2ffc2-bafd-48f7-b8b7-090638afe209", func(q *dbx.SelectQuery) error {
|
||||
}, nil, true},
|
||||
{"demo2", "0yxhwia2amd8gec", func(q *dbx.SelectQuery) error {
|
||||
return errors.New("test error")
|
||||
}, nil, true},
|
||||
{"demo2", "0yxhwia2amd8gec", func(q *dbx.SelectQuery) error {
|
||||
q.AndWhere(dbx.HashExp{"title": "test3"})
|
||||
return nil
|
||||
}, nil, false},
|
||||
{"demo2", "0yxhwia2amd8gec", func(q *dbx.SelectQuery) error {
|
||||
q.AndWhere(dbx.HashExp{"title": "test3"})
|
||||
return nil
|
||||
}, func(q *dbx.SelectQuery) error {
|
||||
q.AndWhere(dbx.HashExp{"active": false})
|
||||
return nil
|
||||
}, true},
|
||||
{"b5c2ffc2-bafd-48f7-b8b7-090638afe209", func(q *dbx.SelectQuery) error {
|
||||
q.AndWhere(dbx.HashExp{"title": "lorem"})
|
||||
{"sz5l5z67tg7gku0", "0yxhwia2amd8gec", func(q *dbx.SelectQuery) error {
|
||||
q.AndWhere(dbx.HashExp{"title": "test3"})
|
||||
return nil
|
||||
}, func(q *dbx.SelectQuery) error {
|
||||
q.AndWhere(dbx.HashExp{"active": true})
|
||||
return nil
|
||||
}, false},
|
||||
}
|
||||
|
||||
for i, scenario := range scenarios {
|
||||
record, err := app.Dao().FindRecordById(collection, scenario.id, scenario.filter)
|
||||
record, err := app.Dao().FindRecordById(
|
||||
scenario.collectionIdOrName,
|
||||
scenario.id,
|
||||
scenario.filter1,
|
||||
scenario.filter2,
|
||||
)
|
||||
|
||||
hasErr := err != nil
|
||||
if hasErr != scenario.expectError {
|
||||
@@ -70,25 +100,34 @@ func TestFindRecordsByIds(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
collection, _ := app.Dao().FindCollectionByNameOrId("demo")
|
||||
|
||||
scenarios := []struct {
|
||||
ids []string
|
||||
filter func(q *dbx.SelectQuery) error
|
||||
expectTotal int
|
||||
expectError bool
|
||||
collectionIdOrName string
|
||||
ids []string
|
||||
filter1 func(q *dbx.SelectQuery) error
|
||||
filter2 func(q *dbx.SelectQuery) error
|
||||
expectTotal int
|
||||
expectError bool
|
||||
}{
|
||||
{[]string{}, nil, 0, false},
|
||||
{[]string{"00000000-bafd-48f7-b8b7-090638afe209"}, nil, 0, false},
|
||||
{[]string{"b5c2ffc2-bafd-48f7-b8b7-090638afe209"}, nil, 1, false},
|
||||
{"demo2", []string{}, nil, nil, 0, false},
|
||||
{"demo2", []string{""}, nil, nil, 0, false},
|
||||
{"demo2", []string{"missing"}, nil, nil, 0, false},
|
||||
{"missing", []string{"0yxhwia2amd8gec"}, nil, nil, 0, true},
|
||||
{"demo2", []string{"0yxhwia2amd8gec"}, nil, nil, 1, false},
|
||||
{"sz5l5z67tg7gku0", []string{"0yxhwia2amd8gec"}, nil, nil, 1, false},
|
||||
{
|
||||
[]string{"b5c2ffc2-bafd-48f7-b8b7-090638afe209", "848a1dea-5ddd-42d6-a00d-030547bffcfe"},
|
||||
"demo2",
|
||||
[]string{"0yxhwia2amd8gec", "llvuca81nly1qls"},
|
||||
nil,
|
||||
nil,
|
||||
2,
|
||||
false,
|
||||
},
|
||||
{
|
||||
[]string{"b5c2ffc2-bafd-48f7-b8b7-090638afe209", "848a1dea-5ddd-42d6-a00d-030547bffcfe"},
|
||||
"demo2",
|
||||
[]string{"0yxhwia2amd8gec", "llvuca81nly1qls"},
|
||||
func(q *dbx.SelectQuery) error {
|
||||
return nil // empty filter
|
||||
},
|
||||
func(q *dbx.SelectQuery) error {
|
||||
return errors.New("test error")
|
||||
},
|
||||
@@ -96,9 +135,25 @@ func TestFindRecordsByIds(t *testing.T) {
|
||||
true,
|
||||
},
|
||||
{
|
||||
[]string{"b5c2ffc2-bafd-48f7-b8b7-090638afe209", "848a1dea-5ddd-42d6-a00d-030547bffcfe"},
|
||||
"demo2",
|
||||
[]string{"0yxhwia2amd8gec", "llvuca81nly1qls"},
|
||||
func(q *dbx.SelectQuery) error {
|
||||
q.AndWhere(dbx.Like("title", "test").Match(true, true))
|
||||
q.AndWhere(dbx.HashExp{"active": true})
|
||||
return nil
|
||||
},
|
||||
nil,
|
||||
1,
|
||||
false,
|
||||
},
|
||||
{
|
||||
"sz5l5z67tg7gku0",
|
||||
[]string{"0yxhwia2amd8gec", "llvuca81nly1qls"},
|
||||
func(q *dbx.SelectQuery) error {
|
||||
q.AndWhere(dbx.HashExp{"active": true})
|
||||
return nil
|
||||
},
|
||||
func(q *dbx.SelectQuery) error {
|
||||
q.AndWhere(dbx.Not(dbx.HashExp{"title": ""}))
|
||||
return nil
|
||||
},
|
||||
1,
|
||||
@@ -107,7 +162,12 @@ func TestFindRecordsByIds(t *testing.T) {
|
||||
}
|
||||
|
||||
for i, scenario := range scenarios {
|
||||
records, err := app.Dao().FindRecordsByIds(collection, scenario.ids, scenario.filter)
|
||||
records, err := app.Dao().FindRecordsByIds(
|
||||
scenario.collectionIdOrName,
|
||||
scenario.ids,
|
||||
scenario.filter1,
|
||||
scenario.filter2,
|
||||
)
|
||||
|
||||
hasErr := err != nil
|
||||
if hasErr != scenario.expectError {
|
||||
@@ -131,35 +191,53 @@ func TestFindRecordsByExpr(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
collection, _ := app.Dao().FindCollectionByNameOrId("demo")
|
||||
|
||||
scenarios := []struct {
|
||||
expression dbx.Expression
|
||||
expectIds []string
|
||||
expectError bool
|
||||
collectionIdOrName string
|
||||
expressions []dbx.Expression
|
||||
expectIds []string
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
"missing",
|
||||
nil,
|
||||
[]string{},
|
||||
true,
|
||||
},
|
||||
{
|
||||
dbx.HashExp{"id": 123},
|
||||
"demo2",
|
||||
nil,
|
||||
[]string{
|
||||
"achvryl401bhse3",
|
||||
"llvuca81nly1qls",
|
||||
"0yxhwia2amd8gec",
|
||||
},
|
||||
false,
|
||||
},
|
||||
{
|
||||
"demo2",
|
||||
[]dbx.Expression{
|
||||
nil,
|
||||
dbx.HashExp{"id": "123"},
|
||||
},
|
||||
[]string{},
|
||||
false,
|
||||
},
|
||||
{
|
||||
dbx.Like("title", "test").Match(true, true),
|
||||
"sz5l5z67tg7gku0",
|
||||
[]dbx.Expression{
|
||||
dbx.Like("title", "test").Match(true, true),
|
||||
dbx.HashExp{"active": true},
|
||||
},
|
||||
[]string{
|
||||
"848a1dea-5ddd-42d6-a00d-030547bffcfe",
|
||||
"577bd676-aacb-4072-b7da-99d00ee210a4",
|
||||
"achvryl401bhse3",
|
||||
"0yxhwia2amd8gec",
|
||||
},
|
||||
false,
|
||||
},
|
||||
}
|
||||
|
||||
for i, scenario := range scenarios {
|
||||
records, err := app.Dao().FindRecordsByExpr(collection, scenario.expression)
|
||||
records, err := app.Dao().FindRecordsByExpr(scenario.collectionIdOrName, scenario.expressions...)
|
||||
|
||||
hasErr := err != nil
|
||||
if hasErr != scenario.expectError {
|
||||
@@ -183,42 +261,52 @@ func TestFindFirstRecordByData(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
collection, _ := app.Dao().FindCollectionByNameOrId("demo")
|
||||
|
||||
scenarios := []struct {
|
||||
key string
|
||||
value any
|
||||
expectId string
|
||||
expectError bool
|
||||
collectionIdOrName string
|
||||
key string
|
||||
value any
|
||||
expectId string
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
"missing",
|
||||
"id",
|
||||
"llvuca81nly1qls",
|
||||
"llvuca81nly1qls",
|
||||
true,
|
||||
},
|
||||
{
|
||||
"demo2",
|
||||
"",
|
||||
"848a1dea-5ddd-42d6-a00d-030547bffcfe",
|
||||
"llvuca81nly1qls",
|
||||
"",
|
||||
true,
|
||||
},
|
||||
{
|
||||
"demo2",
|
||||
"id",
|
||||
"invalid",
|
||||
"",
|
||||
true,
|
||||
},
|
||||
{
|
||||
"demo2",
|
||||
"id",
|
||||
"848a1dea-5ddd-42d6-a00d-030547bffcfe",
|
||||
"848a1dea-5ddd-42d6-a00d-030547bffcfe",
|
||||
"llvuca81nly1qls",
|
||||
"llvuca81nly1qls",
|
||||
false,
|
||||
},
|
||||
{
|
||||
"sz5l5z67tg7gku0",
|
||||
"title",
|
||||
"lorem",
|
||||
"b5c2ffc2-bafd-48f7-b8b7-090638afe209",
|
||||
"test3",
|
||||
"0yxhwia2amd8gec",
|
||||
false,
|
||||
},
|
||||
}
|
||||
|
||||
for i, scenario := range scenarios {
|
||||
record, err := app.Dao().FindFirstRecordByData(collection, scenario.key, scenario.value)
|
||||
record, err := app.Dao().FindFirstRecordByData(scenario.collectionIdOrName, scenario.key, scenario.value)
|
||||
|
||||
hasErr := err != nil
|
||||
if hasErr != scenario.expectError {
|
||||
@@ -236,32 +324,44 @@ func TestIsRecordValueUnique(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
collection, _ := app.Dao().FindCollectionByNameOrId("demo4")
|
||||
|
||||
testManyRelsId1 := "df55c8ff-45ef-4c82-8aed-6e2183fe1125"
|
||||
testManyRelsId2 := "b84cd893-7119-43c9-8505-3c4e22da28a9"
|
||||
testManyRelsId1 := "bgs820n361vj1qd"
|
||||
testManyRelsId2 := "4q1xlclmfloku33"
|
||||
testManyRelsId3 := "oap640cot4yru2s"
|
||||
|
||||
scenarios := []struct {
|
||||
key string
|
||||
value any
|
||||
excludeId string
|
||||
expected bool
|
||||
collectionIdOrName string
|
||||
key string
|
||||
value any
|
||||
excludeIds []string
|
||||
expected bool
|
||||
}{
|
||||
{"", "", "", false},
|
||||
{"missing", "unique", "", false},
|
||||
{"title", "unique", "", true},
|
||||
{"title", "demo1", "", false},
|
||||
{"title", "demo1", "054f9f24-0a0a-4e09-87b1-bc7ff2b336a2", true},
|
||||
{"manyrels", []string{testManyRelsId2}, "", false},
|
||||
{"manyrels", []any{testManyRelsId2}, "", false},
|
||||
// with exclude
|
||||
{"manyrels", []string{testManyRelsId1, testManyRelsId2}, "b8ba58f9-e2d7-42a0-b0e7-a11efd98236b", true},
|
||||
// reverse order
|
||||
{"manyrels", []string{testManyRelsId2, testManyRelsId1}, "", true},
|
||||
{"demo2", "", "", nil, false},
|
||||
{"demo2", "", "", []string{""}, false},
|
||||
{"demo2", "missing", "unique", nil, false},
|
||||
{"demo2", "title", "unique", nil, true},
|
||||
{"demo2", "title", "unique", []string{}, true},
|
||||
{"demo2", "title", "unique", []string{""}, true},
|
||||
{"demo2", "title", "test1", []string{""}, false},
|
||||
{"demo2", "title", "test1", []string{"llvuca81nly1qls"}, true},
|
||||
{"demo1", "rel_many", []string{testManyRelsId3}, nil, false},
|
||||
{"wsmn24bux7wo113", "rel_many", []any{testManyRelsId3}, []string{""}, false},
|
||||
{"wsmn24bux7wo113", "rel_many", []any{testManyRelsId3}, []string{"84nmscqy84lsi1t"}, true},
|
||||
// mixed json array order
|
||||
{"demo1", "rel_many", []string{testManyRelsId1, testManyRelsId3, testManyRelsId2}, nil, true},
|
||||
// username special case-insensitive match
|
||||
{"users", "username", "test2_username", nil, false},
|
||||
{"users", "username", "TEST2_USERNAME", nil, false},
|
||||
{"users", "username", "new_username", nil, true},
|
||||
{"users", "username", "TEST2_USERNAME", []string{"oap640cot4yru2s"}, true},
|
||||
}
|
||||
|
||||
for i, scenario := range scenarios {
|
||||
result := app.Dao().IsRecordValueUnique(collection, scenario.key, scenario.value, scenario.excludeId)
|
||||
result := app.Dao().IsRecordValueUnique(
|
||||
scenario.collectionIdOrName,
|
||||
scenario.key,
|
||||
scenario.value,
|
||||
scenario.excludeIds...,
|
||||
)
|
||||
|
||||
if result != scenario.expected {
|
||||
t.Errorf("(%d) Expected %v, got %v", i, scenario.expected, result)
|
||||
@@ -269,43 +369,164 @@ func TestIsRecordValueUnique(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindUserRelatedRecords(t *testing.T) {
|
||||
func TestFindAuthRecordByToken(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
u0 := &models.User{}
|
||||
u1, _ := app.Dao().FindUserByEmail("test3@example.com")
|
||||
u2, _ := app.Dao().FindUserByEmail("test2@example.com")
|
||||
|
||||
scenarios := []struct {
|
||||
user *models.User
|
||||
expectedIds []string
|
||||
token string
|
||||
baseKey string
|
||||
expectedEmail string
|
||||
expectError bool
|
||||
}{
|
||||
{u0, []string{}},
|
||||
{u1, []string{
|
||||
"94568ca2-0bee-49d7-b749-06cb97956fd9", // demo2
|
||||
"fc69274d-ca5c-416a-b9ef-561b101cfbb1", // profile
|
||||
}},
|
||||
{u2, []string{
|
||||
"b2d5e39d-f569-4cc1-b593-3f074ad026bf", // profile
|
||||
}},
|
||||
// invalid auth token
|
||||
{
|
||||
"eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.H2KKcIXiAfxvuXMFzizo1SgsinDP4hcWhD3pYoP4Nqw",
|
||||
app.Settings().RecordAuthToken.Secret,
|
||||
"",
|
||||
true,
|
||||
},
|
||||
// expired token
|
||||
{
|
||||
"eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoxNjQwOTkxNjYxfQ.HqvpCpM0RAk3Qu9PfCMuZsk_DKh9UYuzFLwXBMTZd1w",
|
||||
app.Settings().RecordAuthToken.Secret,
|
||||
"",
|
||||
true,
|
||||
},
|
||||
// wrong base key (password reset token secret instead of auth secret)
|
||||
{
|
||||
"eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
|
||||
app.Settings().RecordPasswordResetToken.Secret,
|
||||
"",
|
||||
true,
|
||||
},
|
||||
// valid token and base key but with deleted/missing collection
|
||||
{
|
||||
"eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoibWlzc2luZyIsImV4cCI6MjIwODk4NTI2MX0.0oEHQpdpHp0Nb3VN8La0ssg-SjwWKiRl_k1mUGxdKlU",
|
||||
app.Settings().RecordAuthToken.Secret,
|
||||
"test@example.com",
|
||||
true,
|
||||
},
|
||||
// valid token
|
||||
{
|
||||
"eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
|
||||
app.Settings().RecordAuthToken.Secret,
|
||||
"test@example.com",
|
||||
false,
|
||||
},
|
||||
}
|
||||
|
||||
for i, scenario := range scenarios {
|
||||
records, err := app.Dao().FindUserRelatedRecords(scenario.user)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
record, err := app.Dao().FindAuthRecordByToken(scenario.token, scenario.baseKey)
|
||||
|
||||
if len(records) != len(scenario.expectedIds) {
|
||||
t.Errorf("(%d) Expected %d records, got %d (%v)", i, len(scenario.expectedIds), len(records), records)
|
||||
hasErr := err != nil
|
||||
if hasErr != scenario.expectError {
|
||||
t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, scenario.expectError, hasErr, err)
|
||||
continue
|
||||
}
|
||||
|
||||
for _, r := range records {
|
||||
if !list.ExistInSlice(r.Id, scenario.expectedIds) {
|
||||
t.Errorf("(%d) Couldn't find %s in %v", i, r.Id, scenario.expectedIds)
|
||||
}
|
||||
if !scenario.expectError && record.Email() != scenario.expectedEmail {
|
||||
t.Errorf("(%d) Expected record model %s, got %s", i, scenario.expectedEmail, record.Email())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindAuthRecordByEmail(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
scenarios := []struct {
|
||||
collectionIdOrName string
|
||||
email string
|
||||
expectError bool
|
||||
}{
|
||||
{"missing", "test@example.com", true},
|
||||
{"demo2", "test@example.com", true},
|
||||
{"users", "missing@example.com", true},
|
||||
{"users", "test@example.com", false},
|
||||
{"clients", "test2@example.com", false},
|
||||
}
|
||||
|
||||
for i, scenario := range scenarios {
|
||||
record, err := app.Dao().FindAuthRecordByEmail(scenario.collectionIdOrName, scenario.email)
|
||||
|
||||
hasErr := err != nil
|
||||
if hasErr != scenario.expectError {
|
||||
t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, scenario.expectError, hasErr, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if !scenario.expectError && record.Email() != scenario.email {
|
||||
t.Errorf("(%d) Expected record with email %s, got %s", i, scenario.email, record.Email())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindAuthRecordByUsername(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
scenarios := []struct {
|
||||
collectionIdOrName string
|
||||
username string
|
||||
expectError bool
|
||||
}{
|
||||
{"missing", "test_username", true},
|
||||
{"demo2", "test_username", true},
|
||||
{"users", "missing", true},
|
||||
{"users", "test2_username", false},
|
||||
{"users", "TEST2_USERNAME", false}, // case insensitive check
|
||||
{"clients", "clients43362", false},
|
||||
}
|
||||
|
||||
for i, scenario := range scenarios {
|
||||
record, err := app.Dao().FindAuthRecordByUsername(scenario.collectionIdOrName, scenario.username)
|
||||
|
||||
hasErr := err != nil
|
||||
if hasErr != scenario.expectError {
|
||||
t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, scenario.expectError, hasErr, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if !scenario.expectError && !strings.EqualFold(record.Username(), scenario.username) {
|
||||
t.Errorf("(%d) Expected record with username %s, got %s", i, scenario.username, record.Username())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuggestUniqueAuthRecordUsername(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
scenarios := []struct {
|
||||
collectionIdOrName string
|
||||
baseUsername string
|
||||
expectedPattern string
|
||||
}{
|
||||
// missing collection
|
||||
{"missing", "test2_username", `^test2_username\d{12}$`},
|
||||
// not an auth collection
|
||||
{"demo2", "test2_username", `^test2_username\d{12}$`},
|
||||
// auth collection with unique base username
|
||||
{"users", "new_username", `^new_username$`},
|
||||
{"users", "NEW_USERNAME", `^NEW_USERNAME$`},
|
||||
// auth collection with existing username
|
||||
{"users", "test2_username", `^test2_username\d{3}$`},
|
||||
{"users", "TEST2_USERNAME", `^TEST2_USERNAME\d{3}$`},
|
||||
}
|
||||
|
||||
for i, scenario := range scenarios {
|
||||
username := app.Dao().SuggestUniqueAuthRecordUsername(
|
||||
scenario.collectionIdOrName,
|
||||
scenario.baseUsername,
|
||||
)
|
||||
|
||||
pattern, err := regexp.Compile(scenario.expectedPattern)
|
||||
if err != nil {
|
||||
t.Errorf("[%d] Invalid username pattern %q: %v", i, scenario.expectedPattern, err)
|
||||
}
|
||||
if !pattern.MatchString(username) {
|
||||
t.Fatalf("Expected username to match %s, got username %s", scenario.expectedPattern, username)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -314,32 +535,64 @@ func TestSaveRecord(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
collection, _ := app.Dao().FindCollectionByNameOrId("demo")
|
||||
collection, _ := app.Dao().FindCollectionByNameOrId("demo2")
|
||||
|
||||
// create
|
||||
// ---
|
||||
r1 := models.NewRecord(collection)
|
||||
r1.SetDataValue("title", "test_new")
|
||||
r1.Set("title", "test_new")
|
||||
err1 := app.Dao().SaveRecord(r1)
|
||||
if err1 != nil {
|
||||
t.Fatal(err1)
|
||||
}
|
||||
newR1, _ := app.Dao().FindFirstRecordByData(collection, "title", "test_new")
|
||||
if newR1 == nil || newR1.Id != r1.Id || newR1.GetStringDataValue("title") != r1.GetStringDataValue("title") {
|
||||
t.Errorf("Expected to find record %v, got %v", r1, newR1)
|
||||
newR1, _ := app.Dao().FindFirstRecordByData(collection.Id, "title", "test_new")
|
||||
if newR1 == nil || newR1.Id != r1.Id || newR1.GetString("title") != r1.GetString("title") {
|
||||
t.Fatalf("Expected to find record %v, got %v", r1, newR1)
|
||||
}
|
||||
|
||||
// update
|
||||
// ---
|
||||
r2, _ := app.Dao().FindFirstRecordByData(collection, "id", "b5c2ffc2-bafd-48f7-b8b7-090638afe209")
|
||||
r2.SetDataValue("title", "test_update")
|
||||
r2, _ := app.Dao().FindFirstRecordByData(collection.Id, "id", "0yxhwia2amd8gec")
|
||||
r2.Set("title", "test_update")
|
||||
err2 := app.Dao().SaveRecord(r2)
|
||||
if err2 != nil {
|
||||
t.Fatal(err2)
|
||||
}
|
||||
newR2, _ := app.Dao().FindFirstRecordByData(collection, "title", "test_update")
|
||||
if newR2 == nil || newR2.Id != r2.Id || newR2.GetStringDataValue("title") != r2.GetStringDataValue("title") {
|
||||
t.Errorf("Expected to find record %v, got %v", r2, newR2)
|
||||
newR2, _ := app.Dao().FindFirstRecordByData(collection.Id, "title", "test_update")
|
||||
if newR2 == nil || newR2.Id != r2.Id || newR2.GetString("title") != r2.GetString("title") {
|
||||
t.Fatalf("Expected to find record %v, got %v", r2, newR2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveRecordWithIdFromOtherCollection(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
baseCollection, _ := app.Dao().FindCollectionByNameOrId("demo2")
|
||||
authCollection, _ := app.Dao().FindCollectionByNameOrId("nologin")
|
||||
|
||||
// base collection test
|
||||
r1 := models.NewRecord(baseCollection)
|
||||
r1.Set("title", "test_new")
|
||||
r1.Set("id", "mk5fmymtx4wsprk") // existing id of demo3 record
|
||||
r1.MarkAsNew()
|
||||
if err := app.Dao().SaveRecord(r1); err != nil {
|
||||
t.Fatalf("Expected nil, got error %v", err)
|
||||
}
|
||||
|
||||
// auth collection test
|
||||
r2 := models.NewRecord(authCollection)
|
||||
r2.Set("username", "test_new")
|
||||
r2.Set("id", "gk390qegs4y47wn") // existing id of "clients" record
|
||||
r2.MarkAsNew()
|
||||
if err := app.Dao().SaveRecord(r2); err == nil {
|
||||
t.Fatal("Expected error, got nil")
|
||||
}
|
||||
|
||||
// try again with unique id
|
||||
r2.Set("id", "unique_id")
|
||||
if err := app.Dao().SaveRecord(r2); err != nil {
|
||||
t.Fatalf("Expected nil, got error %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -347,130 +600,225 @@ func TestDeleteRecord(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
demo, _ := app.Dao().FindCollectionByNameOrId("demo")
|
||||
demo2, _ := app.Dao().FindCollectionByNameOrId("demo2")
|
||||
demoCollection, _ := app.Dao().FindCollectionByNameOrId("demo2")
|
||||
|
||||
// delete unsaved record
|
||||
// ---
|
||||
rec1 := models.NewRecord(demo)
|
||||
err1 := app.Dao().DeleteRecord(rec1)
|
||||
if err1 == nil {
|
||||
t.Fatal("(rec1) Didn't expect to succeed deleting new record")
|
||||
rec0 := models.NewRecord(demoCollection)
|
||||
if err := app.Dao().DeleteRecord(rec0); err == nil {
|
||||
t.Fatal("(rec0) Didn't expect to succeed deleting unsaved record")
|
||||
}
|
||||
|
||||
// delete existing record + external auths
|
||||
// ---
|
||||
rec1, _ := app.Dao().FindRecordById("users", "4q1xlclmfloku33")
|
||||
if err := app.Dao().DeleteRecord(rec1); err != nil {
|
||||
t.Fatalf("(rec1) Expected nil, got error %v", err)
|
||||
}
|
||||
// check if it was really deleted
|
||||
if refreshed, _ := app.Dao().FindRecordById(rec1.Collection().Id, rec1.Id); refreshed != nil {
|
||||
t.Fatalf("(rec1) Expected record to be deleted, got %v", refreshed)
|
||||
}
|
||||
// check if the external auths were deleted
|
||||
if auths, _ := app.Dao().FindAllExternalAuthsByRecord(rec1); len(auths) > 0 {
|
||||
t.Fatalf("(rec1) Expected external auths to be deleted, got %v", auths)
|
||||
}
|
||||
|
||||
// delete existing record while being part of a non-cascade required relation
|
||||
// ---
|
||||
rec2, _ := app.Dao().FindFirstRecordByData(demo, "id", "848a1dea-5ddd-42d6-a00d-030547bffcfe")
|
||||
err2 := app.Dao().DeleteRecord(rec2)
|
||||
if err2 == nil {
|
||||
rec2, _ := app.Dao().FindRecordById("demo3", "7nwo8tuiatetxdm")
|
||||
if err := app.Dao().DeleteRecord(rec2); err == nil {
|
||||
t.Fatalf("(rec2) Expected error, got nil")
|
||||
}
|
||||
|
||||
// delete existing record
|
||||
// delete existing record + cascade
|
||||
// ---
|
||||
rec3, _ := app.Dao().FindFirstRecordByData(demo, "id", "577bd676-aacb-4072-b7da-99d00ee210a4")
|
||||
err3 := app.Dao().DeleteRecord(rec3)
|
||||
if err3 != nil {
|
||||
t.Fatalf("(rec3) Expected nil, got error %v", err3)
|
||||
calledQueries := []string{}
|
||||
app.Dao().NonconcurrentDB().(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
|
||||
calledQueries = append(calledQueries, sql)
|
||||
}
|
||||
app.Dao().ConcurrentDB().(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
|
||||
calledQueries = append(calledQueries, sql)
|
||||
}
|
||||
app.Dao().NonconcurrentDB().(*dbx.DB).ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {
|
||||
calledQueries = append(calledQueries, sql)
|
||||
}
|
||||
app.Dao().ConcurrentDB().(*dbx.DB).ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {
|
||||
calledQueries = append(calledQueries, sql)
|
||||
}
|
||||
rec3, _ := app.Dao().FindRecordById("users", "oap640cot4yru2s")
|
||||
// delete
|
||||
if err := app.Dao().DeleteRecord(rec3); err != nil {
|
||||
t.Fatalf("(rec3) Expected nil, got error %v", err)
|
||||
}
|
||||
|
||||
// check if it was really deleted
|
||||
rec3, _ = app.Dao().FindRecordById(demo, rec3.Id, nil)
|
||||
rec3, _ = app.Dao().FindRecordById(rec3.Collection().Id, rec3.Id)
|
||||
if rec3 != nil {
|
||||
t.Fatalf("(rec3) Expected record to be deleted, got %v", rec3)
|
||||
}
|
||||
|
||||
// check if the operation cascaded
|
||||
rel, _ := app.Dao().FindFirstRecordByData(demo2, "id", "63c2ab80-84ab-4057-a592-4604a731f78f")
|
||||
rel, _ := app.Dao().FindRecordById("demo1", "84nmscqy84lsi1t")
|
||||
if rel != nil {
|
||||
t.Fatalf("(rec3) Expected the delete to cascade, found relation %v", rel)
|
||||
}
|
||||
// ensure that the json rel fields were prefixed
|
||||
joinedQueries := strings.Join(calledQueries, " ")
|
||||
expectedRelManyJoin := "`demo1` INNER JOIN json_each(CASE WHEN json_valid([[demo1.rel_many]]) THEN [[demo1.rel_many]] ELSE json_array([[demo1.rel_many]]) END)"
|
||||
if !strings.Contains(joinedQueries, expectedRelManyJoin) {
|
||||
t.Fatalf("(rec3) Expected the cascade delete to call the query \n%v, got \n%v", expectedRelManyJoin, calledQueries)
|
||||
}
|
||||
expectedRelOneJoin := "`demo1` INNER JOIN json_each(CASE WHEN json_valid([[demo1.rel_one]]) THEN [[demo1.rel_one]] ELSE json_array([[demo1.rel_one]]) END)"
|
||||
if !strings.Contains(joinedQueries, expectedRelOneJoin) {
|
||||
t.Fatalf("(rec3) Expected the cascade delete to call the query \n%v, got \n%v", expectedRelOneJoin, calledQueries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncRecordTableSchema(t *testing.T) {
|
||||
func TestDeleteRecordBatchProcessing(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
oldCollection, err := app.Dao().FindCollectionByNameOrId("demo")
|
||||
if err != nil {
|
||||
if err := createMockBatchProcessingData(app.Dao()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
updatedCollection, err := app.Dao().FindCollectionByNameOrId("demo")
|
||||
if err != nil {
|
||||
|
||||
// find and delete the first c1 record to trigger cascade
|
||||
mainRecord, _ := app.Dao().FindRecordById("c1", "a")
|
||||
if err := app.Dao().DeleteRecord(mainRecord); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
updatedCollection.Name = "demo_renamed"
|
||||
updatedCollection.Schema.RemoveField(updatedCollection.Schema.GetFieldByName("file").Id)
|
||||
updatedCollection.Schema.AddField(
|
||||
&schema.SchemaField{
|
||||
Name: "new_field",
|
||||
Type: schema.FieldTypeEmail,
|
||||
},
|
||||
)
|
||||
updatedCollection.Schema.AddField(
|
||||
&schema.SchemaField{
|
||||
Id: updatedCollection.Schema.GetFieldByName("title").Id,
|
||||
Name: "title_renamed",
|
||||
Type: schema.FieldTypeEmail,
|
||||
},
|
||||
)
|
||||
|
||||
scenarios := []struct {
|
||||
newCollection *models.Collection
|
||||
oldCollection *models.Collection
|
||||
expectedTableName string
|
||||
expectedColumns []string
|
||||
}{
|
||||
{
|
||||
&models.Collection{
|
||||
Name: "new_table",
|
||||
Schema: schema.NewSchema(
|
||||
&schema.SchemaField{
|
||||
Name: "test",
|
||||
Type: schema.FieldTypeText,
|
||||
},
|
||||
),
|
||||
},
|
||||
nil,
|
||||
"new_table",
|
||||
[]string{"id", "created", "updated", "test"},
|
||||
},
|
||||
// no changes
|
||||
{
|
||||
oldCollection,
|
||||
oldCollection,
|
||||
"demo",
|
||||
[]string{"id", "created", "updated", "title", "file"},
|
||||
},
|
||||
// renamed table, deleted column, renamed columnd and new column
|
||||
{
|
||||
updatedCollection,
|
||||
oldCollection,
|
||||
"demo_renamed",
|
||||
[]string{"id", "created", "updated", "title_renamed", "new_field"},
|
||||
},
|
||||
// check if the main record was deleted
|
||||
_, err := app.Dao().FindRecordById(mainRecord.Collection().Id, mainRecord.Id)
|
||||
if err == nil {
|
||||
t.Fatal("The main record wasn't deleted")
|
||||
}
|
||||
|
||||
for i, scenario := range scenarios {
|
||||
err := app.Dao().SyncRecordTableSchema(scenario.newCollection, scenario.oldCollection)
|
||||
if err != nil {
|
||||
t.Errorf("(%d) %v", i, err)
|
||||
continue
|
||||
}
|
||||
// check if the c1 b rel field were updated
|
||||
c1RecordB, err := app.Dao().FindRecordById("c1", "b")
|
||||
if err != nil || c1RecordB.GetString("rel") != "" {
|
||||
t.Fatalf("Expected c1RecordB.rel to be nil, got %v", c1RecordB.GetString("rel"))
|
||||
}
|
||||
|
||||
if !app.Dao().HasTable(scenario.newCollection.Name) {
|
||||
t.Errorf("(%d) Expected table %s to exist", i, scenario.newCollection.Name)
|
||||
// check if the c2 rel fields were updated
|
||||
c2Records, err := app.Dao().FindRecordsByExpr("c2", nil)
|
||||
if err != nil || len(c2Records) == 0 {
|
||||
t.Fatalf("Failed to fetch c2 records: %v", err)
|
||||
}
|
||||
for _, r := range c2Records {
|
||||
ids := r.GetStringSlice("rel")
|
||||
if len(ids) != 1 || ids[0] != "b" {
|
||||
t.Fatalf("Expected only 'b' rel id, got %v", ids)
|
||||
}
|
||||
}
|
||||
|
||||
cols, _ := app.Dao().GetTableColumns(scenario.newCollection.Name)
|
||||
if len(cols) != len(scenario.expectedColumns) {
|
||||
t.Errorf("(%d) Expected columns %v, got %v", i, scenario.expectedColumns, cols)
|
||||
}
|
||||
|
||||
for _, c := range cols {
|
||||
if !list.ExistInSlice(c, scenario.expectedColumns) {
|
||||
t.Errorf("(%d) Couldn't find column %s in %v", i, c, scenario.expectedColumns)
|
||||
}
|
||||
}
|
||||
// check if all c3 relations were deleted
|
||||
c3Records, err := app.Dao().FindRecordsByExpr("c3", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to fetch c3 records: %v", err)
|
||||
}
|
||||
if total := len(c3Records); total != 0 {
|
||||
t.Fatalf("Expected c3 records to be deleted, found %d", total)
|
||||
}
|
||||
}
|
||||
|
||||
func createMockBatchProcessingData(dao *daos.Dao) error {
|
||||
// create mock collection without relation
|
||||
c1 := &models.Collection{}
|
||||
c1.Id = "c1"
|
||||
c1.Name = c1.Id
|
||||
c1.Schema = schema.NewSchema(
|
||||
&schema.SchemaField{
|
||||
Name: "text",
|
||||
Type: schema.FieldTypeText,
|
||||
},
|
||||
// self reference
|
||||
&schema.SchemaField{
|
||||
Name: "rel",
|
||||
Type: schema.FieldTypeRelation,
|
||||
Options: &schema.RelationOptions{
|
||||
MaxSelect: types.Pointer(1),
|
||||
CollectionId: "c1",
|
||||
CascadeDelete: false, // should unset all rel fields
|
||||
},
|
||||
},
|
||||
)
|
||||
if err := dao.SaveCollection(c1); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// create mock collection with a multi-rel field
|
||||
c2 := &models.Collection{}
|
||||
c2.Id = "c2"
|
||||
c2.Name = c2.Id
|
||||
c2.Schema = schema.NewSchema(
|
||||
&schema.SchemaField{
|
||||
Name: "rel",
|
||||
Type: schema.FieldTypeRelation,
|
||||
Options: &schema.RelationOptions{
|
||||
MaxSelect: types.Pointer(10),
|
||||
CollectionId: "c1",
|
||||
CascadeDelete: false, // should unset all rel fields
|
||||
},
|
||||
},
|
||||
)
|
||||
if err := dao.SaveCollection(c2); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// create mock collection with a single-rel field
|
||||
c3 := &models.Collection{}
|
||||
c3.Id = "c3"
|
||||
c3.Name = c3.Id
|
||||
c3.Schema = schema.NewSchema(
|
||||
&schema.SchemaField{
|
||||
Name: "rel",
|
||||
Type: schema.FieldTypeRelation,
|
||||
Options: &schema.RelationOptions{
|
||||
MaxSelect: types.Pointer(1),
|
||||
CollectionId: "c1",
|
||||
CascadeDelete: true, // should delete all c3 records
|
||||
},
|
||||
},
|
||||
)
|
||||
if err := dao.SaveCollection(c3); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// insert mock records
|
||||
c1RecordA := models.NewRecord(c1)
|
||||
c1RecordA.Id = "a"
|
||||
c1RecordA.Set("rel", c1RecordA.Id) // self reference
|
||||
if err := dao.Save(c1RecordA); err != nil {
|
||||
return err
|
||||
}
|
||||
c1RecordB := models.NewRecord(c1)
|
||||
c1RecordB.Id = "b"
|
||||
c1RecordB.Set("rel", c1RecordA.Id) // rel to another record from the same collection
|
||||
if err := dao.Save(c1RecordB); err != nil {
|
||||
return err
|
||||
}
|
||||
for i := 0; i < 4500; i++ {
|
||||
c2Record := models.NewRecord(c2)
|
||||
c2Record.Set("rel", []string{c1RecordA.Id, c1RecordB.Id})
|
||||
if err := dao.Save(c2Record); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c3Record := models.NewRecord(c3)
|
||||
c3Record.Set("rel", c1RecordA.Id)
|
||||
if err := dao.Save(c3Record); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// set the same id as the relation for at least 1 record
|
||||
// to check whether the correct condition will be added
|
||||
c3Record := models.NewRecord(c3)
|
||||
c3Record.Set("rel", c1RecordA.Id)
|
||||
c3Record.Id = c1RecordA.Id
|
||||
if err := dao.Save(c3Record); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ func TestRequestsStats(t *testing.T) {
|
||||
|
||||
tests.MockRequestLogsData(app)
|
||||
|
||||
expected := `[{"total":1,"date":"2022-05-01 10:00:00.000"},{"total":1,"date":"2022-05-02 10:00:00.000"}]`
|
||||
expected := `[{"total":1,"date":"2022-05-01 10:00:00.000Z"},{"total":1,"date":"2022-05-02 10:00:00.000Z"}]`
|
||||
|
||||
now := time.Now().UTC().Format(types.DefaultDateLayout)
|
||||
exp := dbx.NewExp("[[created]] <= {:date}", dbx.Params{"date": now})
|
||||
@@ -84,10 +84,10 @@ func TestDeleteOldRequests(t *testing.T) {
|
||||
date string
|
||||
expectedTotal int
|
||||
}{
|
||||
{"2022-01-01 10:00:00.000", 2}, // no requests to delete before that time
|
||||
{"2022-05-01 11:00:00.000", 1}, // only 1 request should have left
|
||||
{"2022-05-03 11:00:00.000", 0}, // no more requests should have left
|
||||
{"2022-05-04 11:00:00.000", 0}, // no more requests should have left
|
||||
{"2022-01-01 10:00:00.000Z", 2}, // no requests to delete before that time
|
||||
{"2022-05-01 11:00:00.000Z", 1}, // only 1 request should have left
|
||||
{"2022-05-03 11:00:00.000Z", 0}, // no more requests should have left
|
||||
{"2022-05-04 11:00:00.000Z", 0}, // no more requests should have left
|
||||
}
|
||||
|
||||
for i, scenario := range scenarios {
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package daos
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/models/settings"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
)
|
||||
|
||||
// FindSettings returns and decode the serialized app settings param value.
|
||||
//
|
||||
// The method will first try to decode the param value without decryption.
|
||||
// If it fails and optEncryptionKey is set, it will try again by first
|
||||
// decrypting the value and then decode it again.
|
||||
//
|
||||
// Returns an error if it fails to decode the stored serialized param value.
|
||||
func (dao *Dao) FindSettings(optEncryptionKey ...string) (*settings.Settings, error) {
|
||||
param, err := dao.FindParamByKey(models.ParamAppSettings)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := settings.New()
|
||||
|
||||
// try first without decryption
|
||||
plainDecodeErr := json.Unmarshal(param.Value, result)
|
||||
|
||||
// failed, try to decrypt
|
||||
if plainDecodeErr != nil {
|
||||
var encryptionKey string
|
||||
if len(optEncryptionKey) > 0 && optEncryptionKey[0] != "" {
|
||||
encryptionKey = optEncryptionKey[0]
|
||||
}
|
||||
|
||||
// load without decrypt has failed and there is no encryption key to use for decrypt
|
||||
if encryptionKey == "" {
|
||||
return nil, errors.New("failed to load the stored app settings - missing or invalid encryption key")
|
||||
}
|
||||
|
||||
// decrypt
|
||||
decrypted, decryptErr := security.Decrypt(string(param.Value), encryptionKey)
|
||||
if decryptErr != nil {
|
||||
return nil, decryptErr
|
||||
}
|
||||
|
||||
// decode again
|
||||
decryptedDecodeErr := json.Unmarshal(decrypted, result)
|
||||
if decryptedDecodeErr != nil {
|
||||
return nil, decryptedDecodeErr
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// SaveSettings persists the specified settings configuration.
|
||||
//
|
||||
// If optEncryptionKey is set, then the stored serialized value will be encrypted with it.
|
||||
func (dao *Dao) SaveSettings(newSettings *settings.Settings, optEncryptionKey ...string) error {
|
||||
return dao.SaveParam(models.ParamAppSettings, newSettings, optEncryptionKey...)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package daos_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
)
|
||||
|
||||
func TestSaveAndFindSettings(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
encryptionKey := security.PseudorandomString(32)
|
||||
|
||||
// change unencrypted app settings
|
||||
app.Settings().Meta.AppName = "save_unencrypted"
|
||||
if err := app.Dao().SaveSettings(app.Settings()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// check if the change was persisted
|
||||
s1, err := app.Dao().FindSettings()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to fetch settings: %v", err)
|
||||
}
|
||||
if s1.Meta.AppName != "save_unencrypted" {
|
||||
t.Fatalf("Expected settings to be changed with app name %q, got \n%v", "save_unencrypted", s1)
|
||||
}
|
||||
|
||||
// make another change but this time provide an encryption key
|
||||
app.Settings().Meta.AppName = "save_encrypted"
|
||||
if err := app.Dao().SaveSettings(app.Settings(), encryptionKey); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// try to fetch the settings without encryption key (should fail)
|
||||
if s2, err := app.Dao().FindSettings(); err == nil {
|
||||
t.Fatalf("Expected FindSettings to fail without an encryption key, got \n%v", s2)
|
||||
}
|
||||
|
||||
// try again but this time with an encryption key
|
||||
s3, err := app.Dao().FindSettings(encryptionKey)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to fetch settings with an encryption key %s: %v", encryptionKey, err)
|
||||
}
|
||||
if s3.Meta.AppName != "save_encrypted" {
|
||||
t.Fatalf("Expected settings to be changed with app name %q, got \n%v", "save_encrypted", s3)
|
||||
}
|
||||
}
|
||||
@@ -35,3 +35,11 @@ func (dao *Dao) DeleteTable(tableName string) error {
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// Vacuum executes VACUUM on the current dao.DB() instance in order to
|
||||
// reclaim unused db disk space.
|
||||
func (dao *Dao) Vacuum() error {
|
||||
_, err := dao.DB().NewQuery("VACUUM").Execute()
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
package daos_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
"github.com/pocketbase/pocketbase/tools/list"
|
||||
@@ -79,3 +82,28 @@ func TestDeleteTable(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestVacuum(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
calledQueries := []string{}
|
||||
app.DB().QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
|
||||
calledQueries = append(calledQueries, sql)
|
||||
}
|
||||
app.DB().ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {
|
||||
calledQueries = append(calledQueries, sql)
|
||||
}
|
||||
|
||||
if err := app.Dao().Vacuum(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if total := len(calledQueries); total != 1 {
|
||||
t.Fatalf("Expected 1 query, got %d", total)
|
||||
}
|
||||
|
||||
if calledQueries[0] != "VACUUM" {
|
||||
t.Fatalf("Expected VACUUM query, got %s", calledQueries[0])
|
||||
}
|
||||
}
|
||||
|
||||
-281
@@ -1,281 +0,0 @@
|
||||
package daos
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/models/schema"
|
||||
"github.com/pocketbase/pocketbase/tools/list"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
)
|
||||
|
||||
// UserQuery returns a new User model select query.
|
||||
func (dao *Dao) UserQuery() *dbx.SelectQuery {
|
||||
return dao.ModelQuery(&models.User{})
|
||||
}
|
||||
|
||||
// LoadProfile loads the profile record associated to the provided user.
|
||||
func (dao *Dao) LoadProfile(user *models.User) error {
|
||||
collection, err := dao.FindCollectionByNameOrId(models.ProfileCollectionName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
profile, err := dao.FindFirstRecordByData(collection, models.ProfileCollectionUserFieldName, user.Id)
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
return err
|
||||
}
|
||||
|
||||
user.Profile = profile
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadProfiles loads the profile records associated to the provied users list.
|
||||
func (dao *Dao) LoadProfiles(users []*models.User) error {
|
||||
collection, err := dao.FindCollectionByNameOrId(models.ProfileCollectionName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// extract user ids
|
||||
ids := make([]string, len(users))
|
||||
usersMap := map[string]*models.User{}
|
||||
for i, user := range users {
|
||||
ids[i] = user.Id
|
||||
usersMap[user.Id] = user
|
||||
}
|
||||
|
||||
profiles, err := dao.FindRecordsByExpr(collection, dbx.HashExp{
|
||||
models.ProfileCollectionUserFieldName: list.ToInterfaceSlice(ids),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// populate each user.Profile member
|
||||
for _, profile := range profiles {
|
||||
userId := profile.GetStringDataValue(models.ProfileCollectionUserFieldName)
|
||||
user, ok := usersMap[userId]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
user.Profile = profile
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// FindUserById finds a single User model by its id.
|
||||
//
|
||||
// This method also auto loads the related user profile record
|
||||
// into the found model.
|
||||
func (dao *Dao) FindUserById(id string) (*models.User, error) {
|
||||
model := &models.User{}
|
||||
|
||||
err := dao.UserQuery().
|
||||
AndWhere(dbx.HashExp{"id": id}).
|
||||
Limit(1).
|
||||
One(model)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// try to load the user profile (if exist)
|
||||
if err := dao.LoadProfile(model); err != nil {
|
||||
log.Println(err)
|
||||
}
|
||||
|
||||
return model, nil
|
||||
}
|
||||
|
||||
// FindUserByEmail finds a single User model by its email address.
|
||||
//
|
||||
// This method also auto loads the related user profile record
|
||||
// into the found model.
|
||||
func (dao *Dao) FindUserByEmail(email string) (*models.User, error) {
|
||||
model := &models.User{}
|
||||
|
||||
err := dao.UserQuery().
|
||||
AndWhere(dbx.HashExp{"email": email}).
|
||||
Limit(1).
|
||||
One(model)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// try to load the user profile (if exist)
|
||||
if err := dao.LoadProfile(model); err != nil {
|
||||
log.Println(err)
|
||||
}
|
||||
|
||||
return model, nil
|
||||
}
|
||||
|
||||
// FindUserByToken finds the user associated with the provided JWT token.
|
||||
// Returns an error if the JWT token is invalid or expired.
|
||||
//
|
||||
// This method also auto loads the related user profile record
|
||||
// into the found model.
|
||||
func (dao *Dao) FindUserByToken(token string, baseTokenKey string) (*models.User, error) {
|
||||
unverifiedClaims, err := security.ParseUnverifiedJWT(token)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// check required claims
|
||||
id, _ := unverifiedClaims["id"].(string)
|
||||
if id == "" {
|
||||
return nil, errors.New("Missing or invalid token claims.")
|
||||
}
|
||||
|
||||
user, err := dao.FindUserById(id)
|
||||
if err != nil || user == nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
verificationKey := user.TokenKey + baseTokenKey
|
||||
|
||||
// verify token signature
|
||||
if _, err := security.ParseJWT(token, verificationKey); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// IsUserEmailUnique checks if the provided email address is not
|
||||
// already in use by other users.
|
||||
func (dao *Dao) IsUserEmailUnique(email string, excludeId string) bool {
|
||||
if email == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
var exists bool
|
||||
err := dao.UserQuery().
|
||||
Select("count(*)").
|
||||
AndWhere(dbx.Not(dbx.HashExp{"id": excludeId})).
|
||||
AndWhere(dbx.HashExp{"email": email}).
|
||||
Limit(1).
|
||||
Row(&exists)
|
||||
|
||||
return err == nil && !exists
|
||||
}
|
||||
|
||||
// DeleteUser deletes the provided User model.
|
||||
//
|
||||
// This method will also cascade the delete operation to all
|
||||
// Record models that references the provided User model
|
||||
// (delete or set to NULL, depending on the related user shema field settings).
|
||||
//
|
||||
// The delete operation may fail if the user is part of a required
|
||||
// reference in another Record model (aka. cannot be deleted or set to NULL).
|
||||
func (dao *Dao) DeleteUser(user *models.User) error {
|
||||
// fetch related records
|
||||
// note: the select is outside of the transaction to prevent SQLITE_LOCKED error when mixing read&write in a single transaction
|
||||
relatedRecords, err := dao.FindUserRelatedRecords(user)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return dao.RunInTransaction(func(txDao *Dao) error {
|
||||
// check if related records has to be deleted (if `CascadeDelete` is set)
|
||||
// OR
|
||||
// just unset the user related fields (if they are not required)
|
||||
// -----------------------------------------------------------
|
||||
recordsLoop:
|
||||
for _, record := range relatedRecords {
|
||||
var needSave bool
|
||||
|
||||
for _, field := range record.Collection().Schema.Fields() {
|
||||
if field.Type != schema.FieldTypeUser {
|
||||
continue // not a user field
|
||||
}
|
||||
|
||||
ids := record.GetStringSliceDataValue(field.Name)
|
||||
|
||||
// unset the user id
|
||||
for i := len(ids) - 1; i >= 0; i-- {
|
||||
if ids[i] == user.Id {
|
||||
ids = append(ids[:i], ids[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
options, _ := field.Options.(*schema.UserOptions)
|
||||
|
||||
// cascade delete
|
||||
// (only if there are no other user references in case of multiple select)
|
||||
if options.CascadeDelete && len(ids) == 0 {
|
||||
if err := txDao.DeleteRecord(record); err != nil {
|
||||
return err
|
||||
}
|
||||
// no need to further iterate the user fields (the record is deleted)
|
||||
continue recordsLoop
|
||||
}
|
||||
|
||||
if field.Required && len(ids) == 0 {
|
||||
return fmt.Errorf("Failed delete the user because a record exist with required user reference to the current model (%q, %q).", record.Id, record.Collection().Name)
|
||||
}
|
||||
|
||||
// apply the reference changes
|
||||
record.SetDataValue(field.Name, field.PrepareValue(ids))
|
||||
needSave = true
|
||||
}
|
||||
|
||||
if needSave {
|
||||
if err := txDao.SaveRecord(record); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
// -----------------------------------------------------------
|
||||
|
||||
return txDao.Delete(user)
|
||||
})
|
||||
}
|
||||
|
||||
// SaveUser upserts the provided User model.
|
||||
//
|
||||
// An empty profile record will be created if the user
|
||||
// doesn't have a profile record set yet.
|
||||
func (dao *Dao) SaveUser(user *models.User) error {
|
||||
profileCollection, err := dao.FindCollectionByNameOrId(models.ProfileCollectionName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// fetch the related user profile record (if exist)
|
||||
var userProfile *models.Record
|
||||
if user.HasId() {
|
||||
userProfile, _ = dao.FindFirstRecordByData(
|
||||
profileCollection,
|
||||
models.ProfileCollectionUserFieldName,
|
||||
user.Id,
|
||||
)
|
||||
}
|
||||
|
||||
return dao.RunInTransaction(func(txDao *Dao) error {
|
||||
if err := txDao.Save(user); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// create default/empty profile record if doesn't exist
|
||||
if userProfile == nil {
|
||||
userProfile = models.NewRecord(profileCollection)
|
||||
userProfile.SetDataValue(models.ProfileCollectionUserFieldName, user.Id)
|
||||
if err := txDao.Save(userProfile); err != nil {
|
||||
return err
|
||||
}
|
||||
user.Profile = userProfile
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -1,274 +0,0 @@
|
||||
package daos_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
)
|
||||
|
||||
func TestUserQuery(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
expected := "SELECT {{_users}}.* FROM `_users`"
|
||||
|
||||
sql := app.Dao().UserQuery().Build().SQL()
|
||||
if sql != expected {
|
||||
t.Errorf("Expected sql %s, got %s", expected, sql)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadProfile(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
// try to load missing profile (shouldn't return an error)
|
||||
// ---
|
||||
newUser := &models.User{}
|
||||
err1 := app.Dao().LoadProfile(newUser)
|
||||
if err1 != nil {
|
||||
t.Fatalf("Expected nil, got error %v", err1)
|
||||
}
|
||||
|
||||
// try to load existing profile
|
||||
// ---
|
||||
existingUser, _ := app.Dao().FindUserByEmail("test@example.com")
|
||||
existingUser.Profile = nil // reset
|
||||
|
||||
err2 := app.Dao().LoadProfile(existingUser)
|
||||
if err2 != nil {
|
||||
t.Fatal(err2)
|
||||
}
|
||||
|
||||
if existingUser.Profile == nil {
|
||||
t.Fatal("Expected user profile to be loaded, got nil")
|
||||
}
|
||||
|
||||
if existingUser.Profile.GetStringDataValue("name") != "test" {
|
||||
t.Fatalf("Expected profile.name to be 'test', got %s", existingUser.Profile.GetStringDataValue("name"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadProfiles(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
u0 := &models.User{}
|
||||
u1, _ := app.Dao().FindUserByEmail("test@example.com")
|
||||
u2, _ := app.Dao().FindUserByEmail("test2@example.com")
|
||||
|
||||
users := []*models.User{u0, u1, u2}
|
||||
|
||||
err := app.Dao().LoadProfiles(users)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if u0.Profile != nil {
|
||||
t.Errorf("Expected profile to be nil for u0, got %v", u0.Profile)
|
||||
}
|
||||
if u1.Profile == nil {
|
||||
t.Errorf("Expected profile to be set for u1, got nil")
|
||||
}
|
||||
if u2.Profile == nil {
|
||||
t.Errorf("Expected profile to be set for u2, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindUserById(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
scenarios := []struct {
|
||||
id string
|
||||
expectError bool
|
||||
}{
|
||||
{"00000000-2b4a-a26b-4d01-42d3c3d77bc8", true},
|
||||
{"97cc3d3d-6ba2-383f-b42a-7bc84d27410c", false},
|
||||
}
|
||||
|
||||
for i, scenario := range scenarios {
|
||||
user, err := app.Dao().FindUserById(scenario.id)
|
||||
|
||||
hasErr := err != nil
|
||||
if hasErr != scenario.expectError {
|
||||
t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, scenario.expectError, hasErr, err)
|
||||
}
|
||||
|
||||
if user != nil && user.Id != scenario.id {
|
||||
t.Errorf("(%d) Expected user with id %s, got %s", i, scenario.id, user.Id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindUserByEmail(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
scenarios := []struct {
|
||||
email string
|
||||
expectError bool
|
||||
}{
|
||||
{"invalid", true},
|
||||
{"missing@example.com", true},
|
||||
{"test@example.com", false},
|
||||
}
|
||||
|
||||
for i, scenario := range scenarios {
|
||||
user, err := app.Dao().FindUserByEmail(scenario.email)
|
||||
|
||||
hasErr := err != nil
|
||||
if hasErr != scenario.expectError {
|
||||
t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, scenario.expectError, hasErr, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if !scenario.expectError && user.Email != scenario.email {
|
||||
t.Errorf("(%d) Expected user with email %s, got %s", i, scenario.email, user.Email)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindUserByToken(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
scenarios := []struct {
|
||||
token string
|
||||
baseKey string
|
||||
expectedEmail string
|
||||
expectError bool
|
||||
}{
|
||||
// invalid base key (password reset key for auth token)
|
||||
{
|
||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
|
||||
app.Settings().UserPasswordResetToken.Secret,
|
||||
"",
|
||||
true,
|
||||
},
|
||||
// expired token
|
||||
{
|
||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxNjQwOTkxNjYxfQ.RrSG5NwysI38DEZrIQiz3lUgI6sEuYGTll_jLRbBSiw",
|
||||
app.Settings().UserAuthToken.Secret,
|
||||
"",
|
||||
true,
|
||||
},
|
||||
// valid token
|
||||
{
|
||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
|
||||
app.Settings().UserAuthToken.Secret,
|
||||
"test@example.com",
|
||||
false,
|
||||
},
|
||||
}
|
||||
|
||||
for i, scenario := range scenarios {
|
||||
user, err := app.Dao().FindUserByToken(scenario.token, scenario.baseKey)
|
||||
|
||||
hasErr := err != nil
|
||||
if hasErr != scenario.expectError {
|
||||
t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, scenario.expectError, hasErr, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if !scenario.expectError && user.Email != scenario.expectedEmail {
|
||||
t.Errorf("(%d) Expected user model %s, got %s", i, scenario.expectedEmail, user.Email)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsUserEmailUnique(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
scenarios := []struct {
|
||||
email string
|
||||
excludeId string
|
||||
expected bool
|
||||
}{
|
||||
{"", "", false},
|
||||
{"test@example.com", "", false},
|
||||
{"new@example.com", "", true},
|
||||
{"test@example.com", "4d0197cc-2b4a-3f83-a26b-d77bc8423d3c", true},
|
||||
}
|
||||
|
||||
for i, scenario := range scenarios {
|
||||
result := app.Dao().IsUserEmailUnique(scenario.email, scenario.excludeId)
|
||||
if result != scenario.expected {
|
||||
t.Errorf("(%d) Expected %v, got %v", i, scenario.expected, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteUser(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
// try to delete unsaved user
|
||||
// ---
|
||||
err1 := app.Dao().DeleteUser(&models.User{})
|
||||
if err1 == nil {
|
||||
t.Fatal("Expected error, got nil")
|
||||
}
|
||||
|
||||
// try to delete existing user
|
||||
// ---
|
||||
user, _ := app.Dao().FindUserByEmail("test3@example.com")
|
||||
err2 := app.Dao().DeleteUser(user)
|
||||
if err2 != nil {
|
||||
t.Fatalf("Expected nil, got error %v", err2)
|
||||
}
|
||||
|
||||
// check if the delete operation was cascaded to the profiles collection (record delete)
|
||||
profilesCol, _ := app.Dao().FindCollectionByNameOrId(models.ProfileCollectionName)
|
||||
profile, _ := app.Dao().FindRecordById(profilesCol, user.Profile.Id, nil)
|
||||
if profile != nil {
|
||||
t.Fatalf("Expected user profile to be deleted, got %v", profile)
|
||||
}
|
||||
|
||||
// check if delete operation was cascaded to the related demo2 collection (null set)
|
||||
demo2Col, _ := app.Dao().FindCollectionByNameOrId("demo2")
|
||||
record, _ := app.Dao().FindRecordById(demo2Col, "94568ca2-0bee-49d7-b749-06cb97956fd9", nil)
|
||||
if record == nil {
|
||||
t.Fatal("Expected to found related record, got nil")
|
||||
}
|
||||
if record.GetStringDataValue("user") != "" {
|
||||
t.Fatalf("Expected user field to be set to empty string, got %v", record.GetStringDataValue("user"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveUser(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
// create
|
||||
// ---
|
||||
u1 := &models.User{}
|
||||
u1.Email = "new@example.com"
|
||||
u1.SetPassword("123456")
|
||||
err1 := app.Dao().SaveUser(u1)
|
||||
if err1 != nil {
|
||||
t.Fatal(err1)
|
||||
}
|
||||
u1, refreshErr1 := app.Dao().FindUserByEmail("new@example.com")
|
||||
if refreshErr1 != nil {
|
||||
t.Fatalf("Expected user with email new@example.com to have been created, got error %v", refreshErr1)
|
||||
}
|
||||
if u1.Profile == nil {
|
||||
t.Fatalf("Expected creating a user to create also an empty profile record")
|
||||
}
|
||||
|
||||
// update
|
||||
// ---
|
||||
u2, _ := app.Dao().FindUserByEmail("test@example.com")
|
||||
u2.Email = "test_update@example.com"
|
||||
err2 := app.Dao().SaveUser(u2)
|
||||
if err2 != nil {
|
||||
t.Fatal(err2)
|
||||
}
|
||||
u2, refreshErr2 := app.Dao().FindUserByEmail("test_update@example.com")
|
||||
if u2 == nil {
|
||||
t.Fatalf("Couldn't find user with email test_update@example.com (%v)", refreshErr2)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
# ignore everything
|
||||
/*
|
||||
|
||||
# exclude from the ignore filter
|
||||
!.gitignore
|
||||
!main.go
|
||||
+69
-4
@@ -2,21 +2,77 @@ package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/pocketbase/pocketbase"
|
||||
"github.com/pocketbase/pocketbase/apis"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/plugins/jsvm"
|
||||
"github.com/pocketbase/pocketbase/plugins/migratecmd"
|
||||
)
|
||||
|
||||
func main() {
|
||||
app := pocketbase.New()
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Optional plugin flags:
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
var migrationsDir string
|
||||
app.RootCmd.PersistentFlags().StringVar(
|
||||
&migrationsDir,
|
||||
"migrationsDir",
|
||||
"",
|
||||
"the directory with the user defined migrations",
|
||||
)
|
||||
|
||||
var automigrate bool
|
||||
app.RootCmd.PersistentFlags().BoolVar(
|
||||
&automigrate,
|
||||
"automigrate",
|
||||
true,
|
||||
"enable/disable auto migrations",
|
||||
)
|
||||
|
||||
var publicDir string
|
||||
app.RootCmd.PersistentFlags().StringVar(
|
||||
&publicDir,
|
||||
"publicDir",
|
||||
defaultPublicDir(),
|
||||
"the directory to serve static files",
|
||||
)
|
||||
|
||||
var indexFallback bool
|
||||
app.RootCmd.PersistentFlags().BoolVar(
|
||||
&indexFallback,
|
||||
"indexFallback",
|
||||
true,
|
||||
"fallback the request to index.html on missing static path (eg. when pretty urls are used with SPA)",
|
||||
)
|
||||
|
||||
app.RootCmd.ParseFlags(os.Args[1:])
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Plugins and hooks:
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
// load js pb_migrations
|
||||
jsvm.MustRegisterMigrations(app, &jsvm.MigrationsOptions{
|
||||
Dir: migrationsDir,
|
||||
})
|
||||
|
||||
// migrate command (with js templates)
|
||||
migratecmd.MustRegister(app, app.RootCmd, &migratecmd.Options{
|
||||
TemplateLang: migratecmd.TemplateLangJS,
|
||||
Automigrate: automigrate,
|
||||
Dir: migrationsDir,
|
||||
})
|
||||
|
||||
app.OnBeforeServe().Add(func(e *core.ServeEvent) error {
|
||||
// serves static files from the provided public dir (if exists)
|
||||
subFs := echo.MustSubFS(e.Router.Filesystem, "pb_public")
|
||||
e.Router.GET("/*", apis.StaticDirectoryHandler(subFs, false))
|
||||
|
||||
e.Router.GET("/*", apis.StaticDirectoryHandler(os.DirFS(publicDir), indexFallback))
|
||||
return nil
|
||||
})
|
||||
|
||||
@@ -24,3 +80,12 @@ func main() {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// the default pb_public dir location is relative to the executable
|
||||
func defaultPublicDir() string {
|
||||
if strings.HasPrefix(os.Args[0], os.TempDir()) {
|
||||
// most likely ran with go run
|
||||
return "./pb_public"
|
||||
}
|
||||
return filepath.Join(os.Args[0], "../pb_public")
|
||||
}
|
||||
|
||||
+39
-40
@@ -1,6 +1,7 @@
|
||||
package forms
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
@@ -10,72 +11,70 @@ import (
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
)
|
||||
|
||||
// AdminLogin specifies an admin email/pass login form.
|
||||
// AdminLogin is an admin email/pass login form.
|
||||
type AdminLogin struct {
|
||||
config AdminLoginConfig
|
||||
app core.App
|
||||
dao *daos.Dao
|
||||
|
||||
Email string `form:"email" json:"email"`
|
||||
Identity string `form:"identity" json:"identity"`
|
||||
Password string `form:"password" json:"password"`
|
||||
}
|
||||
|
||||
// AdminLoginConfig is the [AdminLogin] factory initializer config.
|
||||
// NewAdminLogin creates a new [AdminLogin] form initialized with
|
||||
// the provided [core.App] instance.
|
||||
//
|
||||
// NB! App is a required struct member.
|
||||
type AdminLoginConfig struct {
|
||||
App core.App
|
||||
TxDao *daos.Dao
|
||||
}
|
||||
|
||||
// NewAdminLogin creates a new [AdminLogin] form with initializer
|
||||
// config created from the provided [core.App] instance.
|
||||
//
|
||||
// If you want to submit the form as part of another transaction, use
|
||||
// [NewAdminLoginWithConfig] with explicitly set TxDao.
|
||||
// If you want to submit the form as part of a transaction,
|
||||
// you can change the default Dao via [SetDao()].
|
||||
func NewAdminLogin(app core.App) *AdminLogin {
|
||||
return NewAdminLoginWithConfig(AdminLoginConfig{
|
||||
App: app,
|
||||
})
|
||||
return &AdminLogin{
|
||||
app: app,
|
||||
dao: app.Dao(),
|
||||
}
|
||||
}
|
||||
|
||||
// NewAdminLoginWithConfig creates a new [AdminLogin] form
|
||||
// with the provided config or panics on invalid configuration.
|
||||
func NewAdminLoginWithConfig(config AdminLoginConfig) *AdminLogin {
|
||||
form := &AdminLogin{config: config}
|
||||
|
||||
if form.config.App == nil {
|
||||
panic("Missing required config.App instance.")
|
||||
}
|
||||
|
||||
if form.config.TxDao == nil {
|
||||
form.config.TxDao = form.config.App.Dao()
|
||||
}
|
||||
|
||||
return form
|
||||
// SetDao replaces the default form Dao instance with the provided one.
|
||||
func (form *AdminLogin) SetDao(dao *daos.Dao) {
|
||||
form.dao = dao
|
||||
}
|
||||
|
||||
// Validate makes the form validatable by implementing [validation.Validatable] interface.
|
||||
func (form *AdminLogin) Validate() error {
|
||||
return validation.ValidateStruct(form,
|
||||
validation.Field(&form.Email, validation.Required, validation.Length(1, 255), is.Email),
|
||||
validation.Field(&form.Identity, validation.Required, validation.Length(1, 255), is.EmailFormat),
|
||||
validation.Field(&form.Password, validation.Required, validation.Length(1, 255)),
|
||||
)
|
||||
}
|
||||
|
||||
// Submit validates and submits the admin form.
|
||||
// On success returns the authorized admin model.
|
||||
func (form *AdminLogin) Submit() (*models.Admin, error) {
|
||||
//
|
||||
// You can optionally provide a list of InterceptorFunc to
|
||||
// further modify the form behavior before persisting it.
|
||||
func (form *AdminLogin) Submit(interceptors ...InterceptorFunc[*models.Admin]) (*models.Admin, error) {
|
||||
if err := form.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
admin, err := form.config.TxDao.FindAdminByEmail(form.Email)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
admin, fetchErr := form.dao.FindAdminByEmail(form.Identity)
|
||||
|
||||
// ignore not found errors to allow custom fetch implementations
|
||||
if fetchErr != nil && !errors.Is(fetchErr, sql.ErrNoRows) {
|
||||
return nil, fetchErr
|
||||
}
|
||||
|
||||
if admin.ValidatePassword(form.Password) {
|
||||
return admin, nil
|
||||
interceptorsErr := runInterceptors(admin, func(m *models.Admin) error {
|
||||
admin = m
|
||||
|
||||
if admin == nil || !admin.ValidatePassword(form.Password) {
|
||||
return errors.New("Invalid login credentials.")
|
||||
}
|
||||
|
||||
return nil
|
||||
}, interceptors...)
|
||||
|
||||
if interceptorsErr != nil {
|
||||
return nil, interceptorsErr
|
||||
}
|
||||
|
||||
return nil, errors.New("Invalid login credentials.")
|
||||
return admin, nil
|
||||
}
|
||||
|
||||
+50
-44
@@ -1,54 +1,15 @@
|
||||
package forms_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/pocketbase/pocketbase/forms"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
)
|
||||
|
||||
func TestAdminLoginPanic(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("The form did not panic")
|
||||
}
|
||||
}()
|
||||
|
||||
forms.NewAdminLogin(nil)
|
||||
}
|
||||
|
||||
func TestAdminLoginValidate(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
form := forms.NewAdminLogin(app)
|
||||
|
||||
scenarios := []struct {
|
||||
email string
|
||||
password string
|
||||
expectError bool
|
||||
}{
|
||||
{"", "", true},
|
||||
{"", "123", true},
|
||||
{"test@example.com", "", true},
|
||||
{"test", "123", true},
|
||||
{"test@example.com", "123", false},
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
form.Email = s.email
|
||||
form.Password = s.password
|
||||
|
||||
err := form.Validate()
|
||||
|
||||
hasErr := err != nil
|
||||
if hasErr != s.expectError {
|
||||
t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, s.expectError, hasErr, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminLoginSubmit(t *testing.T) {
|
||||
func TestAdminLoginValidateAndSubmit(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -62,14 +23,14 @@ func TestAdminLoginSubmit(t *testing.T) {
|
||||
{"", "", true},
|
||||
{"", "1234567890", true},
|
||||
{"test@example.com", "", true},
|
||||
{"test", "1234567890", true},
|
||||
{"test", "test", true},
|
||||
{"missing@example.com", "1234567890", true},
|
||||
{"test@example.com", "123456789", true},
|
||||
{"test@example.com", "1234567890", false},
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
form.Email = s.email
|
||||
form.Identity = s.email
|
||||
form.Password = s.password
|
||||
|
||||
admin, err := form.Submit()
|
||||
@@ -88,3 +49,48 @@ func TestAdminLoginSubmit(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminLoginInterceptors(t *testing.T) {
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
|
||||
form := forms.NewAdminLogin(testApp)
|
||||
form.Identity = "test@example.com"
|
||||
form.Password = "123456"
|
||||
var interceptorAdmin *models.Admin
|
||||
testErr := errors.New("test_error")
|
||||
|
||||
interceptor1Called := false
|
||||
interceptor1 := func(next forms.InterceptorNextFunc[*models.Admin]) forms.InterceptorNextFunc[*models.Admin] {
|
||||
return func(admin *models.Admin) error {
|
||||
interceptor1Called = true
|
||||
return next(admin)
|
||||
}
|
||||
}
|
||||
|
||||
interceptor2Called := false
|
||||
interceptor2 := func(next forms.InterceptorNextFunc[*models.Admin]) forms.InterceptorNextFunc[*models.Admin] {
|
||||
return func(admin *models.Admin) error {
|
||||
interceptorAdmin = admin
|
||||
interceptor2Called = true
|
||||
return testErr
|
||||
}
|
||||
}
|
||||
|
||||
_, submitErr := form.Submit(interceptor1, interceptor2)
|
||||
if submitErr != testErr {
|
||||
t.Fatalf("Expected submitError %v, got %v", testErr, submitErr)
|
||||
}
|
||||
|
||||
if !interceptor1Called {
|
||||
t.Fatalf("Expected interceptor1 to be called")
|
||||
}
|
||||
|
||||
if !interceptor2Called {
|
||||
t.Fatalf("Expected interceptor2 to be called")
|
||||
}
|
||||
|
||||
if interceptorAdmin == nil || interceptorAdmin.Email != form.Identity {
|
||||
t.Fatalf("Expected Admin model with email %s, got %v", form.Identity, interceptorAdmin)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,55 +8,41 @@ import (
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
)
|
||||
|
||||
// AdminPasswordResetConfirm specifies an admin password reset confirmation form.
|
||||
// AdminPasswordResetConfirm is an admin password reset confirmation form.
|
||||
type AdminPasswordResetConfirm struct {
|
||||
config AdminPasswordResetConfirmConfig
|
||||
app core.App
|
||||
dao *daos.Dao
|
||||
|
||||
Token string `form:"token" json:"token"`
|
||||
Password string `form:"password" json:"password"`
|
||||
PasswordConfirm string `form:"passwordConfirm" json:"passwordConfirm"`
|
||||
}
|
||||
|
||||
// AdminPasswordResetConfirmConfig is the [AdminPasswordResetConfirm] factory initializer config.
|
||||
//
|
||||
// NB! App is required struct member.
|
||||
type AdminPasswordResetConfirmConfig struct {
|
||||
App core.App
|
||||
TxDao *daos.Dao
|
||||
}
|
||||
|
||||
// NewAdminPasswordResetConfirm creates a new [AdminPasswordResetConfirm]
|
||||
// form with initializer config created from the provided [core.App] instance.
|
||||
// form initialized with from the provided [core.App] instance.
|
||||
//
|
||||
// If you want to submit the form as part of another transaction, use
|
||||
// [NewAdminPasswordResetConfirmWithConfig] with explicitly set TxDao.
|
||||
// If you want to submit the form as part of a transaction,
|
||||
// you can change the default Dao via [SetDao()].
|
||||
func NewAdminPasswordResetConfirm(app core.App) *AdminPasswordResetConfirm {
|
||||
return NewAdminPasswordResetConfirmWithConfig(AdminPasswordResetConfirmConfig{
|
||||
App: app,
|
||||
})
|
||||
return &AdminPasswordResetConfirm{
|
||||
app: app,
|
||||
dao: app.Dao(),
|
||||
}
|
||||
}
|
||||
|
||||
// NewAdminPasswordResetConfirmWithConfig creates a new [AdminPasswordResetConfirm]
|
||||
// form with the provided config or panics on invalid configuration.
|
||||
func NewAdminPasswordResetConfirmWithConfig(config AdminPasswordResetConfirmConfig) *AdminPasswordResetConfirm {
|
||||
form := &AdminPasswordResetConfirm{config: config}
|
||||
|
||||
if form.config.App == nil {
|
||||
panic("Missing required config.App instance.")
|
||||
}
|
||||
|
||||
if form.config.TxDao == nil {
|
||||
form.config.TxDao = form.config.App.Dao()
|
||||
}
|
||||
|
||||
return form
|
||||
// SetDao replaces the form Dao instance with the provided one.
|
||||
//
|
||||
// This is useful if you want to use a specific transaction Dao instance
|
||||
// instead of the default app.Dao().
|
||||
func (form *AdminPasswordResetConfirm) SetDao(dao *daos.Dao) {
|
||||
form.dao = dao
|
||||
}
|
||||
|
||||
// Validate makes the form validatable by implementing [validation.Validatable] interface.
|
||||
func (form *AdminPasswordResetConfirm) Validate() error {
|
||||
return validation.ValidateStruct(form,
|
||||
validation.Field(&form.Token, validation.Required, validation.By(form.checkToken)),
|
||||
validation.Field(&form.Password, validation.Required, validation.Length(10, 100)),
|
||||
validation.Field(&form.Password, validation.Required, validation.Length(10, 72)),
|
||||
validation.Field(&form.PasswordConfirm, validation.Required, validation.By(validators.Compare(form.Password))),
|
||||
)
|
||||
}
|
||||
@@ -67,10 +53,7 @@ func (form *AdminPasswordResetConfirm) checkToken(value any) error {
|
||||
return nil // nothing to check
|
||||
}
|
||||
|
||||
admin, err := form.config.TxDao.FindAdminByToken(
|
||||
v,
|
||||
form.config.App.Settings().AdminPasswordResetToken.Secret,
|
||||
)
|
||||
admin, err := form.dao.FindAdminByToken(v, form.app.Settings().AdminPasswordResetToken.Secret)
|
||||
if err != nil || admin == nil {
|
||||
return validation.NewError("validation_invalid_token", "Invalid or expired token.")
|
||||
}
|
||||
@@ -80,14 +63,17 @@ func (form *AdminPasswordResetConfirm) checkToken(value any) error {
|
||||
|
||||
// Submit validates and submits the admin password reset confirmation form.
|
||||
// On success returns the updated admin model associated to `form.Token`.
|
||||
func (form *AdminPasswordResetConfirm) Submit() (*models.Admin, error) {
|
||||
//
|
||||
// You can optionally provide a list of InterceptorFunc to further
|
||||
// modify the form behavior before persisting it.
|
||||
func (form *AdminPasswordResetConfirm) Submit(interceptors ...InterceptorFunc[*models.Admin]) (*models.Admin, error) {
|
||||
if err := form.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
admin, err := form.config.TxDao.FindAdminByToken(
|
||||
admin, err := form.dao.FindAdminByToken(
|
||||
form.Token,
|
||||
form.config.App.Settings().AdminPasswordResetToken.Secret,
|
||||
form.app.Settings().AdminPasswordResetToken.Secret,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -97,8 +83,13 @@ func (form *AdminPasswordResetConfirm) Submit() (*models.Admin, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := form.config.TxDao.SaveAdmin(admin); err != nil {
|
||||
return nil, err
|
||||
interceptorsErr := runInterceptors(admin, func(m *models.Admin) error {
|
||||
admin = m
|
||||
return form.dao.SaveAdmin(m)
|
||||
}, interceptors...)
|
||||
|
||||
if interceptorsErr != nil {
|
||||
return nil, interceptorsErr
|
||||
}
|
||||
|
||||
return admin, nil
|
||||
|
||||
@@ -1,24 +1,16 @@
|
||||
package forms_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/pocketbase/pocketbase/forms"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
)
|
||||
|
||||
func TestAdminPasswordResetPanic(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("The form did not panic")
|
||||
}
|
||||
}()
|
||||
|
||||
forms.NewAdminPasswordResetConfirm(nil)
|
||||
}
|
||||
|
||||
func TestAdminPasswordResetConfirmValidate(t *testing.T) {
|
||||
func TestAdminPasswordResetConfirmValidateAndSubmit(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -38,16 +30,23 @@ func TestAdminPasswordResetConfirmValidate(t *testing.T) {
|
||||
{"test", "123", "123", true},
|
||||
{
|
||||
// expired
|
||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTY0MTAxMzIwMH0.Gp_1b5WVhqjj2o3nJhNUlJmpdiwFLXN72LbMP-26gjA",
|
||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImV4cCI6MTY0MDk5MTY2MX0.GLwCOsgWTTEKXTK-AyGW838de1OeZGIjfHH0FoRLqZg",
|
||||
"1234567890",
|
||||
"1234567890",
|
||||
true,
|
||||
},
|
||||
{
|
||||
// valid
|
||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg5MzQ3NDAwMH0.72IhlL_5CpNGE0ZKM7sV9aAKa3wxQaMZdDiHBo0orpw",
|
||||
"1234567890",
|
||||
// valid with mismatched passwords
|
||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImV4cCI6MjIwODk4MTYwMH0.kwFEler6KSMKJNstuaSDvE1QnNdCta5qSnjaIQ0hhhc",
|
||||
"1234567890",
|
||||
"1234567891",
|
||||
true,
|
||||
},
|
||||
{
|
||||
// valid with matching passwords
|
||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImV4cCI6MjIwODk4MTYwMH0.kwFEler6KSMKJNstuaSDvE1QnNdCta5qSnjaIQ0hhhc",
|
||||
"1234567891",
|
||||
"1234567891",
|
||||
false,
|
||||
},
|
||||
}
|
||||
@@ -57,59 +56,29 @@ func TestAdminPasswordResetConfirmValidate(t *testing.T) {
|
||||
form.Password = s.password
|
||||
form.PasswordConfirm = s.passwordConfirm
|
||||
|
||||
err := form.Validate()
|
||||
|
||||
hasErr := err != nil
|
||||
if hasErr != s.expectError {
|
||||
t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, s.expectError, hasErr, err)
|
||||
interceptorCalls := 0
|
||||
interceptor := func(next forms.InterceptorNextFunc[*models.Admin]) forms.InterceptorNextFunc[*models.Admin] {
|
||||
return func(m *models.Admin) error {
|
||||
interceptorCalls++
|
||||
return next(m)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminPasswordResetConfirmSubmit(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
admin, err := form.Submit(interceptor)
|
||||
|
||||
form := forms.NewAdminPasswordResetConfirm(app)
|
||||
|
||||
scenarios := []struct {
|
||||
token string
|
||||
password string
|
||||
passwordConfirm string
|
||||
expectError bool
|
||||
}{
|
||||
{"", "", "", true},
|
||||
{"", "123", "", true},
|
||||
{"", "", "123", true},
|
||||
{"test", "", "", true},
|
||||
{"test", "123", "", true},
|
||||
{"test", "123", "123", true},
|
||||
{
|
||||
// expired
|
||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTY0MTAxMzIwMH0.Gp_1b5WVhqjj2o3nJhNUlJmpdiwFLXN72LbMP-26gjA",
|
||||
"1234567890",
|
||||
"1234567890",
|
||||
true,
|
||||
},
|
||||
{
|
||||
// valid
|
||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg5MzQ3NDAwMH0.72IhlL_5CpNGE0ZKM7sV9aAKa3wxQaMZdDiHBo0orpw",
|
||||
"1234567890",
|
||||
"1234567890",
|
||||
false,
|
||||
},
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
form.Token = s.token
|
||||
form.Password = s.password
|
||||
form.PasswordConfirm = s.passwordConfirm
|
||||
|
||||
admin, err := form.Submit()
|
||||
// check interceptor calls
|
||||
expectInterceptorCalls := 1
|
||||
if s.expectError {
|
||||
expectInterceptorCalls = 0
|
||||
}
|
||||
if interceptorCalls != expectInterceptorCalls {
|
||||
t.Errorf("[%d] Expected interceptor to be called %d, got %d", i, expectInterceptorCalls, interceptorCalls)
|
||||
}
|
||||
|
||||
hasErr := err != nil
|
||||
if hasErr != s.expectError {
|
||||
t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, s.expectError, hasErr, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if s.expectError {
|
||||
@@ -128,3 +97,54 @@ func TestAdminPasswordResetConfirmSubmit(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminPasswordResetConfirmInterceptors(t *testing.T) {
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
|
||||
admin, err := testApp.Dao().FindAdminByEmail("test@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
form := forms.NewAdminPasswordResetConfirm(testApp)
|
||||
form.Token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImV4cCI6MjIwODk4MTYwMH0.kwFEler6KSMKJNstuaSDvE1QnNdCta5qSnjaIQ0hhhc"
|
||||
form.Password = "1234567891"
|
||||
form.PasswordConfirm = "1234567891"
|
||||
interceptorTokenKey := admin.TokenKey
|
||||
testErr := errors.New("test_error")
|
||||
|
||||
interceptor1Called := false
|
||||
interceptor1 := func(next forms.InterceptorNextFunc[*models.Admin]) forms.InterceptorNextFunc[*models.Admin] {
|
||||
return func(admin *models.Admin) error {
|
||||
interceptor1Called = true
|
||||
return next(admin)
|
||||
}
|
||||
}
|
||||
|
||||
interceptor2Called := false
|
||||
interceptor2 := func(next forms.InterceptorNextFunc[*models.Admin]) forms.InterceptorNextFunc[*models.Admin] {
|
||||
return func(admin *models.Admin) error {
|
||||
interceptorTokenKey = admin.TokenKey
|
||||
interceptor2Called = true
|
||||
return testErr
|
||||
}
|
||||
}
|
||||
|
||||
_, submitErr := form.Submit(interceptor1, interceptor2)
|
||||
if submitErr != testErr {
|
||||
t.Fatalf("Expected submitError %v, got %v", testErr, submitErr)
|
||||
}
|
||||
|
||||
if !interceptor1Called {
|
||||
t.Fatalf("Expected interceptor1 to be called")
|
||||
}
|
||||
|
||||
if !interceptor2Called {
|
||||
t.Fatalf("Expected interceptor2 to be called")
|
||||
}
|
||||
|
||||
if interceptorTokenKey == admin.TokenKey {
|
||||
t.Fatalf("Expected the form model to be filled before calling the interceptors")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,51 +9,35 @@ import (
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
"github.com/pocketbase/pocketbase/mails"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
)
|
||||
|
||||
// AdminPasswordResetRequest specifies an admin password reset request form.
|
||||
// AdminPasswordResetRequest is an admin password reset request form.
|
||||
type AdminPasswordResetRequest struct {
|
||||
config AdminPasswordResetRequestConfig
|
||||
app core.App
|
||||
dao *daos.Dao
|
||||
resendThreshold float64 // in seconds
|
||||
|
||||
Email string `form:"email" json:"email"`
|
||||
}
|
||||
|
||||
// AdminPasswordResetRequestConfig is the [AdminPasswordResetRequest] factory initializer config.
|
||||
//
|
||||
// NB! App is required struct member.
|
||||
type AdminPasswordResetRequestConfig struct {
|
||||
App core.App
|
||||
TxDao *daos.Dao
|
||||
ResendThreshold float64 // in seconds
|
||||
}
|
||||
|
||||
// NewAdminPasswordResetRequest creates a new [AdminPasswordResetRequest]
|
||||
// form with initializer config created from the provided [core.App] instance.
|
||||
// form initialized with from the provided [core.App] instance.
|
||||
//
|
||||
// If you want to submit the form as part of another transaction, use
|
||||
// [NewAdminPasswordResetRequestWithConfig] with explicitly set TxDao.
|
||||
// If you want to submit the form as part of a transaction,
|
||||
// you can change the default Dao via [SetDao()].
|
||||
func NewAdminPasswordResetRequest(app core.App) *AdminPasswordResetRequest {
|
||||
return NewAdminPasswordResetRequestWithConfig(AdminPasswordResetRequestConfig{
|
||||
App: app,
|
||||
ResendThreshold: 120, // 2min
|
||||
})
|
||||
return &AdminPasswordResetRequest{
|
||||
app: app,
|
||||
dao: app.Dao(),
|
||||
resendThreshold: 120, // 2min
|
||||
}
|
||||
}
|
||||
|
||||
// NewAdminPasswordResetRequestWithConfig creates a new [AdminPasswordResetRequest]
|
||||
// form with the provided config or panics on invalid configuration.
|
||||
func NewAdminPasswordResetRequestWithConfig(config AdminPasswordResetRequestConfig) *AdminPasswordResetRequest {
|
||||
form := &AdminPasswordResetRequest{config: config}
|
||||
|
||||
if form.config.App == nil {
|
||||
panic("Missing required config.App instance.")
|
||||
}
|
||||
|
||||
if form.config.TxDao == nil {
|
||||
form.config.TxDao = form.config.App.Dao()
|
||||
}
|
||||
|
||||
return form
|
||||
// SetDao replaces the default form Dao instance with the provided one.
|
||||
func (form *AdminPasswordResetRequest) SetDao(dao *daos.Dao) {
|
||||
form.dao = dao
|
||||
}
|
||||
|
||||
// Validate makes the form validatable by implementing [validation.Validatable] interface.
|
||||
@@ -65,35 +49,40 @@ func (form *AdminPasswordResetRequest) Validate() error {
|
||||
&form.Email,
|
||||
validation.Required,
|
||||
validation.Length(1, 255),
|
||||
is.Email,
|
||||
is.EmailFormat,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// Submit validates and submits the form.
|
||||
// On success sends a password reset email to the `form.Email` admin.
|
||||
func (form *AdminPasswordResetRequest) Submit() error {
|
||||
//
|
||||
// You can optionally provide a list of InterceptorFunc to further
|
||||
// modify the form behavior before persisting it.
|
||||
func (form *AdminPasswordResetRequest) Submit(interceptors ...InterceptorFunc[*models.Admin]) error {
|
||||
if err := form.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
admin, err := form.config.TxDao.FindAdminByEmail(form.Email)
|
||||
admin, err := form.dao.FindAdminByEmail(form.Email)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
lastResetSentAt := admin.LastResetSentAt.Time()
|
||||
if now.Sub(lastResetSentAt).Seconds() < form.config.ResendThreshold {
|
||||
if now.Sub(lastResetSentAt).Seconds() < form.resendThreshold {
|
||||
return errors.New("You have already requested a password reset.")
|
||||
}
|
||||
|
||||
if err := mails.SendAdminPasswordReset(form.config.App, admin); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// update last sent timestamp
|
||||
admin.LastResetSentAt = types.NowDateTime()
|
||||
|
||||
return form.config.TxDao.SaveAdmin(admin)
|
||||
return runInterceptors(admin, func(m *models.Admin) error {
|
||||
if err := mails.SendAdminPasswordReset(form.app, m); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return form.dao.SaveAdmin(m)
|
||||
}, interceptors...)
|
||||
}
|
||||
|
||||
@@ -1,52 +1,15 @@
|
||||
package forms_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/pocketbase/pocketbase/forms"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
)
|
||||
|
||||
func TestAdminPasswordResetRequestPanic(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("The form did not panic")
|
||||
}
|
||||
}()
|
||||
|
||||
forms.NewAdminPasswordResetRequest(nil)
|
||||
}
|
||||
|
||||
func TestAdminPasswordResetRequestValidate(t *testing.T) {
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
|
||||
form := forms.NewAdminPasswordResetRequest(testApp)
|
||||
|
||||
scenarios := []struct {
|
||||
email string
|
||||
expectError bool
|
||||
}{
|
||||
{"", true},
|
||||
{"", true},
|
||||
{"invalid", true},
|
||||
{"missing@example.com", false}, // doesn't check for existing admin
|
||||
{"test@example.com", false},
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
form.Email = s.email
|
||||
|
||||
err := form.Validate()
|
||||
|
||||
hasErr := err != nil
|
||||
if hasErr != s.expectError {
|
||||
t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, s.expectError, hasErr, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminPasswordResetRequestSubmit(t *testing.T) {
|
||||
func TestAdminPasswordResetRequestValidateAndSubmit(t *testing.T) {
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
|
||||
@@ -70,7 +33,24 @@ func TestAdminPasswordResetRequestSubmit(t *testing.T) {
|
||||
|
||||
adminBefore, _ := testApp.Dao().FindAdminByEmail(s.email)
|
||||
|
||||
err := form.Submit()
|
||||
interceptorCalls := 0
|
||||
interceptor := func(next forms.InterceptorNextFunc[*models.Admin]) forms.InterceptorNextFunc[*models.Admin] {
|
||||
return func(m *models.Admin) error {
|
||||
interceptorCalls++
|
||||
return next(m)
|
||||
}
|
||||
}
|
||||
|
||||
err := form.Submit(interceptor)
|
||||
|
||||
// check interceptor calls
|
||||
expectInterceptorCalls := 1
|
||||
if s.expectError {
|
||||
expectInterceptorCalls = 0
|
||||
}
|
||||
if interceptorCalls != expectInterceptorCalls {
|
||||
t.Errorf("[%d] Expected interceptor to be called %d, got %d", i, expectInterceptorCalls, interceptorCalls)
|
||||
}
|
||||
|
||||
hasErr := err != nil
|
||||
if hasErr != s.expectError {
|
||||
@@ -92,3 +72,52 @@ func TestAdminPasswordResetRequestSubmit(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminPasswordResetRequestInterceptors(t *testing.T) {
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
|
||||
admin, err := testApp.Dao().FindAdminByEmail("test@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
form := forms.NewAdminPasswordResetRequest(testApp)
|
||||
form.Email = admin.Email
|
||||
interceptorLastResetSentAt := admin.LastResetSentAt
|
||||
testErr := errors.New("test_error")
|
||||
|
||||
interceptor1Called := false
|
||||
interceptor1 := func(next forms.InterceptorNextFunc[*models.Admin]) forms.InterceptorNextFunc[*models.Admin] {
|
||||
return func(admin *models.Admin) error {
|
||||
interceptor1Called = true
|
||||
return next(admin)
|
||||
}
|
||||
}
|
||||
|
||||
interceptor2Called := false
|
||||
interceptor2 := func(next forms.InterceptorNextFunc[*models.Admin]) forms.InterceptorNextFunc[*models.Admin] {
|
||||
return func(admin *models.Admin) error {
|
||||
interceptorLastResetSentAt = admin.LastResetSentAt
|
||||
interceptor2Called = true
|
||||
return testErr
|
||||
}
|
||||
}
|
||||
|
||||
submitErr := form.Submit(interceptor1, interceptor2)
|
||||
if submitErr != testErr {
|
||||
t.Fatalf("Expected submitError %v, got %v", testErr, submitErr)
|
||||
}
|
||||
|
||||
if !interceptor1Called {
|
||||
t.Fatalf("Expected interceptor1 to be called")
|
||||
}
|
||||
|
||||
if !interceptor2Called {
|
||||
t.Fatalf("Expected interceptor2 to be called")
|
||||
}
|
||||
|
||||
if interceptorLastResetSentAt.String() == admin.LastResetSentAt.String() {
|
||||
t.Fatalf("Expected the form model to be filled before calling the interceptors")
|
||||
}
|
||||
}
|
||||
|
||||
+21
-38
@@ -9,10 +9,11 @@ import (
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
)
|
||||
|
||||
// AdminUpsert specifies a [models.Admin] upsert (create/update) form.
|
||||
// AdminUpsert is a [models.Admin] upsert (create/update) form.
|
||||
type AdminUpsert struct {
|
||||
config AdminUpsertConfig
|
||||
admin *models.Admin
|
||||
app core.App
|
||||
dao *daos.Dao
|
||||
admin *models.Admin
|
||||
|
||||
Id string `form:"id" json:"id"`
|
||||
Avatar int `form:"avatar" json:"avatar"`
|
||||
@@ -21,41 +22,17 @@ type AdminUpsert struct {
|
||||
PasswordConfirm string `form:"passwordConfirm" json:"passwordConfirm"`
|
||||
}
|
||||
|
||||
// AdminUpsertConfig is the [AdminUpsert] factory initializer config.
|
||||
//
|
||||
// NB! App is a required struct member.
|
||||
type AdminUpsertConfig struct {
|
||||
App core.App
|
||||
TxDao *daos.Dao
|
||||
}
|
||||
|
||||
// NewAdminUpsert creates a new [AdminUpsert] form with initializer
|
||||
// config created from the provided [core.App] and [models.Admin] instances
|
||||
// (for create you could pass a pointer to an empty Admin - `&models.Admin{}`).
|
||||
//
|
||||
// If you want to submit the form as part of another transaction, use
|
||||
// [NewAdminUpsertWithConfig] with explicitly set TxDao.
|
||||
// If you want to submit the form as part of a transaction,
|
||||
// you can change the default Dao via [SetDao()].
|
||||
func NewAdminUpsert(app core.App, admin *models.Admin) *AdminUpsert {
|
||||
return NewAdminUpsertWithConfig(AdminUpsertConfig{
|
||||
App: app,
|
||||
}, admin)
|
||||
}
|
||||
|
||||
// NewAdminUpsertWithConfig creates a new [AdminUpsert] form
|
||||
// with the provided config and [models.Admin] instance or panics on invalid configuration
|
||||
// (for create you could pass a pointer to an empty Admin - `&models.Admin{}`).
|
||||
func NewAdminUpsertWithConfig(config AdminUpsertConfig, admin *models.Admin) *AdminUpsert {
|
||||
form := &AdminUpsert{
|
||||
config: config,
|
||||
admin: admin,
|
||||
}
|
||||
|
||||
if form.config.App == nil || form.admin == nil {
|
||||
panic("Invalid initializer config or nil upsert model.")
|
||||
}
|
||||
|
||||
if form.config.TxDao == nil {
|
||||
form.config.TxDao = form.config.App.Dao()
|
||||
app: app,
|
||||
dao: app.Dao(),
|
||||
admin: admin,
|
||||
}
|
||||
|
||||
// load defaults
|
||||
@@ -66,6 +43,11 @@ func NewAdminUpsertWithConfig(config AdminUpsertConfig, admin *models.Admin) *Ad
|
||||
return form
|
||||
}
|
||||
|
||||
// SetDao replaces the default form Dao instance with the provided one.
|
||||
func (form *AdminUpsert) SetDao(dao *daos.Dao) {
|
||||
form.dao = dao
|
||||
}
|
||||
|
||||
// Validate makes the form validatable by implementing [validation.Validatable] interface.
|
||||
func (form *AdminUpsert) Validate() error {
|
||||
return validation.ValidateStruct(form,
|
||||
@@ -75,6 +57,7 @@ func (form *AdminUpsert) Validate() error {
|
||||
form.admin.IsNew(),
|
||||
validation.Length(models.DefaultIdLength, models.DefaultIdLength),
|
||||
validation.Match(idRegex),
|
||||
validation.By(validators.UniqueId(form.dao, form.admin.TableName())),
|
||||
).Else(validation.In(form.admin.Id)),
|
||||
),
|
||||
validation.Field(
|
||||
@@ -86,13 +69,13 @@ func (form *AdminUpsert) Validate() error {
|
||||
&form.Email,
|
||||
validation.Required,
|
||||
validation.Length(1, 255),
|
||||
is.Email,
|
||||
is.EmailFormat,
|
||||
validation.By(form.checkUniqueEmail),
|
||||
),
|
||||
validation.Field(
|
||||
&form.Password,
|
||||
validation.When(form.admin.IsNew(), validation.Required),
|
||||
validation.Length(10, 100),
|
||||
validation.Length(10, 72),
|
||||
),
|
||||
validation.Field(
|
||||
&form.PasswordConfirm,
|
||||
@@ -105,7 +88,7 @@ func (form *AdminUpsert) Validate() error {
|
||||
func (form *AdminUpsert) checkUniqueEmail(value any) error {
|
||||
v, _ := value.(string)
|
||||
|
||||
if form.config.TxDao.IsAdminEmailUnique(v, form.admin.Id) {
|
||||
if form.dao.IsAdminEmailUnique(v, form.admin.Id) {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -116,7 +99,7 @@ func (form *AdminUpsert) checkUniqueEmail(value any) error {
|
||||
//
|
||||
// You can optionally provide a list of InterceptorFunc to further
|
||||
// modify the form behavior before persisting it.
|
||||
func (form *AdminUpsert) Submit(interceptors ...InterceptorFunc) error {
|
||||
func (form *AdminUpsert) Submit(interceptors ...InterceptorFunc[*models.Admin]) error {
|
||||
if err := form.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -134,7 +117,7 @@ func (form *AdminUpsert) Submit(interceptors ...InterceptorFunc) error {
|
||||
form.admin.SetPassword(form.Password)
|
||||
}
|
||||
|
||||
return runInterceptors(func() error {
|
||||
return form.config.TxDao.SaveAdmin(form.admin)
|
||||
return runInterceptors(form.admin, func(admin *models.Admin) error {
|
||||
return form.dao.SaveAdmin(admin)
|
||||
}, interceptors...)
|
||||
}
|
||||
|
||||
+16
-158
@@ -6,35 +6,11 @@ import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/pocketbase/pocketbase/forms"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
)
|
||||
|
||||
func TestAdminUpsertPanic1(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("The form did not panic")
|
||||
}
|
||||
}()
|
||||
|
||||
forms.NewAdminUpsert(nil, nil)
|
||||
}
|
||||
|
||||
func TestAdminUpsertPanic2(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("The form did not panic")
|
||||
}
|
||||
}()
|
||||
|
||||
forms.NewAdminUpsert(app, nil)
|
||||
}
|
||||
|
||||
func TestNewAdminUpsert(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
@@ -54,125 +30,7 @@ func TestNewAdminUpsert(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminUpsertValidate(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
scenarios := []struct {
|
||||
id string
|
||||
avatar int
|
||||
email string
|
||||
password string
|
||||
passwordConfirm string
|
||||
expectedErrors int
|
||||
}{
|
||||
{
|
||||
"",
|
||||
-1,
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
3,
|
||||
},
|
||||
{
|
||||
"",
|
||||
10,
|
||||
"invalid",
|
||||
"12345678",
|
||||
"87654321",
|
||||
4,
|
||||
},
|
||||
{
|
||||
// existing email
|
||||
"2b4a97cc-3f83-4d01-a26b-3d77bc842d3c",
|
||||
3,
|
||||
"test2@example.com",
|
||||
"1234567890",
|
||||
"1234567890",
|
||||
1,
|
||||
},
|
||||
{
|
||||
// mismatching passwords
|
||||
"2b4a97cc-3f83-4d01-a26b-3d77bc842d3c",
|
||||
3,
|
||||
"test@example.com",
|
||||
"1234567890",
|
||||
"1234567891",
|
||||
1,
|
||||
},
|
||||
{
|
||||
// create without setting password
|
||||
"",
|
||||
9,
|
||||
"test_create@example.com",
|
||||
"",
|
||||
"",
|
||||
1,
|
||||
},
|
||||
{
|
||||
// create with existing email
|
||||
"",
|
||||
9,
|
||||
"test@example.com",
|
||||
"1234567890!",
|
||||
"1234567890!",
|
||||
1,
|
||||
},
|
||||
{
|
||||
// update without setting password
|
||||
"2b4a97cc-3f83-4d01-a26b-3d77bc842d3c",
|
||||
3,
|
||||
"test_update@example.com",
|
||||
"",
|
||||
"",
|
||||
0,
|
||||
},
|
||||
{
|
||||
// create with password
|
||||
"",
|
||||
9,
|
||||
"test_create@example.com",
|
||||
"1234567890!",
|
||||
"1234567890!",
|
||||
0,
|
||||
},
|
||||
{
|
||||
// update with password
|
||||
"2b4a97cc-3f83-4d01-a26b-3d77bc842d3c",
|
||||
4,
|
||||
"test_update@example.com",
|
||||
"1234567890",
|
||||
"1234567890",
|
||||
0,
|
||||
},
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
admin := &models.Admin{}
|
||||
if s.id != "" {
|
||||
admin, _ = app.Dao().FindAdminById(s.id)
|
||||
}
|
||||
|
||||
form := forms.NewAdminUpsert(app, admin)
|
||||
form.Avatar = s.avatar
|
||||
form.Email = s.email
|
||||
form.Password = s.password
|
||||
form.PasswordConfirm = s.passwordConfirm
|
||||
|
||||
result := form.Validate()
|
||||
errs, ok := result.(validation.Errors)
|
||||
if !ok && result != nil {
|
||||
t.Errorf("(%d) Failed to parse errors %v", i, result)
|
||||
continue
|
||||
}
|
||||
|
||||
if len(errs) != s.expectedErrors {
|
||||
t.Errorf("(%d) Expected %d errors, got %d (%v)", i, s.expectedErrors, len(errs), errs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminUpsertSubmit(t *testing.T) {
|
||||
func TestAdminUpsertValidateAndSubmit(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -189,7 +47,7 @@ func TestAdminUpsertSubmit(t *testing.T) {
|
||||
},
|
||||
{
|
||||
// update empty
|
||||
"2b4a97cc-3f83-4d01-a26b-3d77bc842d3c",
|
||||
"sywbhecnh46rhm0",
|
||||
`{}`,
|
||||
false,
|
||||
},
|
||||
@@ -225,7 +83,7 @@ func TestAdminUpsertSubmit(t *testing.T) {
|
||||
},
|
||||
{
|
||||
// update failure - existing email
|
||||
"2b4a97cc-3f83-4d01-a26b-3d77bc842d3c",
|
||||
"sywbhecnh46rhm0",
|
||||
`{
|
||||
"email": "test2@example.com"
|
||||
}`,
|
||||
@@ -233,7 +91,7 @@ func TestAdminUpsertSubmit(t *testing.T) {
|
||||
},
|
||||
{
|
||||
// update failure - mismatching passwords
|
||||
"2b4a97cc-3f83-4d01-a26b-3d77bc842d3c",
|
||||
"sywbhecnh46rhm0",
|
||||
`{
|
||||
"password": "1234567890",
|
||||
"passwordConfirm": "1234567891"
|
||||
@@ -241,16 +99,16 @@ func TestAdminUpsertSubmit(t *testing.T) {
|
||||
true,
|
||||
},
|
||||
{
|
||||
// update succcess - new email
|
||||
"2b4a97cc-3f83-4d01-a26b-3d77bc842d3c",
|
||||
// update success - new email
|
||||
"sywbhecnh46rhm0",
|
||||
`{
|
||||
"email": "test_update@example.com"
|
||||
}`,
|
||||
false,
|
||||
},
|
||||
{
|
||||
// update succcess - new password
|
||||
"2b4a97cc-3f83-4d01-a26b-3d77bc842d3c",
|
||||
// update success - new password
|
||||
"sywbhecnh46rhm0",
|
||||
`{
|
||||
"password": "1234567890",
|
||||
"passwordConfirm": "1234567890"
|
||||
@@ -279,10 +137,10 @@ func TestAdminUpsertSubmit(t *testing.T) {
|
||||
|
||||
interceptorCalls := 0
|
||||
|
||||
err := form.Submit(func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
|
||||
return func() error {
|
||||
err := form.Submit(func(next forms.InterceptorNextFunc[*models.Admin]) forms.InterceptorNextFunc[*models.Admin] {
|
||||
return func(m *models.Admin) error {
|
||||
interceptorCalls++
|
||||
return next()
|
||||
return next(m)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -338,16 +196,16 @@ func TestAdminUpsertSubmitInterceptors(t *testing.T) {
|
||||
interceptorAdminEmail := ""
|
||||
|
||||
interceptor1Called := false
|
||||
interceptor1 := func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
|
||||
return func() error {
|
||||
interceptor1 := func(next forms.InterceptorNextFunc[*models.Admin]) forms.InterceptorNextFunc[*models.Admin] {
|
||||
return func(m *models.Admin) error {
|
||||
interceptor1Called = true
|
||||
return next()
|
||||
return next(m)
|
||||
}
|
||||
}
|
||||
|
||||
interceptor2Called := false
|
||||
interceptor2 := func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
|
||||
return func() error {
|
||||
interceptor2 := func(next forms.InterceptorNextFunc[*models.Admin]) forms.InterceptorNextFunc[*models.Admin] {
|
||||
return func(m *models.Admin) error {
|
||||
interceptorAdminEmail = admin.Email // to check if the record was filled
|
||||
interceptor2Called = true
|
||||
return testErr
|
||||
|
||||
+13
-6
@@ -2,23 +2,30 @@
|
||||
// validation and applying changes to existing DB models through the app Dao.
|
||||
package forms
|
||||
|
||||
import "regexp"
|
||||
import (
|
||||
"regexp"
|
||||
)
|
||||
|
||||
// base ID value regex pattern
|
||||
var idRegex = regexp.MustCompile(`^[^\@\#\$\&\|\.\,\'\"\\\/\s]+$`)
|
||||
|
||||
// InterceptorNextFunc is a interceptor handler function.
|
||||
// Usually used in combination with InterceptorFunc.
|
||||
type InterceptorNextFunc = func() error
|
||||
type InterceptorNextFunc[T any] func(t T) error
|
||||
|
||||
// InterceptorFunc defines a single interceptor function that will execute the provided next func handler.
|
||||
type InterceptorFunc func(next InterceptorNextFunc) InterceptorNextFunc
|
||||
// InterceptorFunc defines a single interceptor function that
|
||||
// will execute the provided next func handler.
|
||||
type InterceptorFunc[T any] func(next InterceptorNextFunc[T]) InterceptorNextFunc[T]
|
||||
|
||||
// runInterceptors executes the provided list of interceptors.
|
||||
func runInterceptors(next InterceptorNextFunc, interceptors ...InterceptorFunc) error {
|
||||
func runInterceptors[T any](
|
||||
data T,
|
||||
next InterceptorNextFunc[T],
|
||||
interceptors ...InterceptorFunc[T],
|
||||
) error {
|
||||
for i := len(interceptors) - 1; i >= 0; i-- {
|
||||
next = interceptors[i](next)
|
||||
}
|
||||
|
||||
return next()
|
||||
return next(data)
|
||||
}
|
||||
|
||||
+186
-52
@@ -1,6 +1,7 @@
|
||||
package forms
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
@@ -8,20 +9,25 @@ import (
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
"github.com/pocketbase/pocketbase/forms/validators"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/models/schema"
|
||||
"github.com/pocketbase/pocketbase/resolvers"
|
||||
"github.com/pocketbase/pocketbase/tools/list"
|
||||
"github.com/pocketbase/pocketbase/tools/search"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
)
|
||||
|
||||
var collectionNameRegex = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_]*$`)
|
||||
|
||||
// CollectionUpsert specifies a [models.Collection] upsert (create/update) form.
|
||||
// CollectionUpsert is a [models.Collection] upsert (create/update) form.
|
||||
type CollectionUpsert struct {
|
||||
config CollectionUpsertConfig
|
||||
app core.App
|
||||
dao *daos.Dao
|
||||
collection *models.Collection
|
||||
|
||||
Id string `form:"id" json:"id"`
|
||||
Type string `form:"type" json:"type"`
|
||||
Name string `form:"name" json:"name"`
|
||||
System bool `form:"system" json:"system"`
|
||||
Schema schema.Schema `form:"schema" json:"schema"`
|
||||
@@ -30,47 +36,25 @@ type CollectionUpsert struct {
|
||||
CreateRule *string `form:"createRule" json:"createRule"`
|
||||
UpdateRule *string `form:"updateRule" json:"updateRule"`
|
||||
DeleteRule *string `form:"deleteRule" json:"deleteRule"`
|
||||
}
|
||||
|
||||
// CollectionUpsertConfig is the [CollectionUpsert] factory initializer config.
|
||||
//
|
||||
// NB! App is a required struct member.
|
||||
type CollectionUpsertConfig struct {
|
||||
App core.App
|
||||
TxDao *daos.Dao
|
||||
Options types.JsonMap `form:"options" json:"options"`
|
||||
}
|
||||
|
||||
// NewCollectionUpsert creates a new [CollectionUpsert] form with initializer
|
||||
// config created from the provided [core.App] and [models.Collection] instances
|
||||
// (for create you could pass a pointer to an empty Collection - `&models.Collection{}`).
|
||||
//
|
||||
// If you want to submit the form as part of another transaction, use
|
||||
// [NewCollectionUpsertWithConfig] with explicitly set TxDao.
|
||||
// If you want to submit the form as part of a transaction,
|
||||
// you can change the default Dao via [SetDao()].
|
||||
func NewCollectionUpsert(app core.App, collection *models.Collection) *CollectionUpsert {
|
||||
return NewCollectionUpsertWithConfig(CollectionUpsertConfig{
|
||||
App: app,
|
||||
}, collection)
|
||||
}
|
||||
|
||||
// NewCollectionUpsertWithConfig creates a new [CollectionUpsert] form
|
||||
// with the provided config and [models.Collection] instance or panics on invalid configuration
|
||||
// (for create you could pass a pointer to an empty Collection - `&models.Collection{}`).
|
||||
func NewCollectionUpsertWithConfig(config CollectionUpsertConfig, collection *models.Collection) *CollectionUpsert {
|
||||
form := &CollectionUpsert{
|
||||
config: config,
|
||||
app: app,
|
||||
dao: app.Dao(),
|
||||
collection: collection,
|
||||
}
|
||||
|
||||
if form.config.App == nil || form.collection == nil {
|
||||
panic("Invalid initializer config or nil upsert model.")
|
||||
}
|
||||
|
||||
if form.config.TxDao == nil {
|
||||
form.config.TxDao = form.config.App.Dao()
|
||||
}
|
||||
|
||||
// load defaults
|
||||
form.Id = form.collection.Id
|
||||
form.Type = form.collection.Type
|
||||
form.Name = form.collection.Name
|
||||
form.System = form.collection.System
|
||||
form.ListRule = form.collection.ListRule
|
||||
@@ -78,6 +62,11 @@ func NewCollectionUpsertWithConfig(config CollectionUpsertConfig, collection *mo
|
||||
form.CreateRule = form.collection.CreateRule
|
||||
form.UpdateRule = form.collection.UpdateRule
|
||||
form.DeleteRule = form.collection.DeleteRule
|
||||
form.Options = form.collection.Options
|
||||
|
||||
if form.Type == "" {
|
||||
form.Type = models.CollectionTypeBase
|
||||
}
|
||||
|
||||
clone, _ := form.collection.Schema.Clone()
|
||||
if clone != nil {
|
||||
@@ -89,8 +78,15 @@ func NewCollectionUpsertWithConfig(config CollectionUpsertConfig, collection *mo
|
||||
return form
|
||||
}
|
||||
|
||||
// SetDao replaces the default form Dao instance with the provided one.
|
||||
func (form *CollectionUpsert) SetDao(dao *daos.Dao) {
|
||||
form.dao = dao
|
||||
}
|
||||
|
||||
// Validate makes the form validatable by implementing [validation.Validatable] interface.
|
||||
func (form *CollectionUpsert) Validate() error {
|
||||
isAuth := form.Type == models.CollectionTypeAuth
|
||||
|
||||
return validation.ValidateStruct(form,
|
||||
validation.Field(
|
||||
&form.Id,
|
||||
@@ -98,12 +94,19 @@ func (form *CollectionUpsert) Validate() error {
|
||||
form.collection.IsNew(),
|
||||
validation.Length(models.DefaultIdLength, models.DefaultIdLength),
|
||||
validation.Match(idRegex),
|
||||
validation.By(validators.UniqueId(form.dao, form.collection.TableName())),
|
||||
).Else(validation.In(form.collection.Id)),
|
||||
),
|
||||
validation.Field(
|
||||
&form.System,
|
||||
validation.By(form.ensureNoSystemFlagChange),
|
||||
),
|
||||
validation.Field(
|
||||
&form.Type,
|
||||
validation.Required,
|
||||
validation.In(models.CollectionTypeAuth, models.CollectionTypeBase),
|
||||
validation.By(form.ensureNoTypeChange),
|
||||
),
|
||||
validation.Field(
|
||||
&form.Name,
|
||||
validation.Required,
|
||||
@@ -115,26 +118,39 @@ func (form *CollectionUpsert) Validate() error {
|
||||
// validates using the type's own validation rules + some collection's specific
|
||||
validation.Field(
|
||||
&form.Schema,
|
||||
validation.By(form.checkMinSchemaFields),
|
||||
validation.By(form.ensureNoSystemFieldsChange),
|
||||
validation.By(form.ensureNoFieldsTypeChange),
|
||||
validation.By(form.ensureExistingRelationCollectionId),
|
||||
validation.By(form.checkRelationFields),
|
||||
validation.When(
|
||||
isAuth,
|
||||
validation.By(form.ensureNoAuthFieldName),
|
||||
),
|
||||
),
|
||||
validation.Field(&form.ListRule, validation.By(form.checkRule)),
|
||||
validation.Field(&form.ViewRule, validation.By(form.checkRule)),
|
||||
validation.Field(&form.CreateRule, validation.By(form.checkRule)),
|
||||
validation.Field(&form.UpdateRule, validation.By(form.checkRule)),
|
||||
validation.Field(&form.DeleteRule, validation.By(form.checkRule)),
|
||||
validation.Field(&form.Options, validation.By(form.checkOptions)),
|
||||
)
|
||||
}
|
||||
|
||||
func (form *CollectionUpsert) checkUniqueName(value any) error {
|
||||
v, _ := value.(string)
|
||||
|
||||
if !form.config.TxDao.IsCollectionNameUnique(v, form.collection.Id) {
|
||||
// ensure unique collection name
|
||||
if !form.dao.IsCollectionNameUnique(v, form.collection.Id) {
|
||||
return validation.NewError("validation_collection_name_exists", "Collection name must be unique (case insensitive).")
|
||||
}
|
||||
|
||||
if (form.collection.IsNew() || !strings.EqualFold(v, form.collection.Name)) && form.config.TxDao.HasTable(v) {
|
||||
// ensure that the collection name doesn't collide with the id of any collection
|
||||
if form.dao.FindById(&models.Collection{}, v) == nil {
|
||||
return validation.NewError("validation_collection_name_id_duplicate", "The name must not match an existing collection id.")
|
||||
}
|
||||
|
||||
// ensure that there is no existing table name with the same name
|
||||
if (form.collection.IsNew() || !strings.EqualFold(v, form.collection.Name)) && form.dao.HasTable(v) {
|
||||
return validation.NewError("validation_collection_name_table_exists", "The collection name must be also unique table name.")
|
||||
}
|
||||
|
||||
@@ -144,21 +160,31 @@ func (form *CollectionUpsert) checkUniqueName(value any) error {
|
||||
func (form *CollectionUpsert) ensureNoSystemNameChange(value any) error {
|
||||
v, _ := value.(string)
|
||||
|
||||
if form.collection.IsNew() || !form.collection.System || v == form.collection.Name {
|
||||
return nil
|
||||
if !form.collection.IsNew() && form.collection.System && v != form.collection.Name {
|
||||
return validation.NewError("validation_collection_system_name_change", "System collections cannot be renamed.")
|
||||
}
|
||||
|
||||
return validation.NewError("validation_system_collection_name_change", "System collections cannot be renamed.")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (form *CollectionUpsert) ensureNoSystemFlagChange(value any) error {
|
||||
v, _ := value.(bool)
|
||||
|
||||
if form.collection.IsNew() || v == form.collection.System {
|
||||
return nil
|
||||
if !form.collection.IsNew() && v != form.collection.System {
|
||||
return validation.NewError("validation_collection_system_flag_change", "System collection state cannot be changed.")
|
||||
}
|
||||
|
||||
return validation.NewError("validation_system_collection_flag_change", "System collection state cannot be changed.")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (form *CollectionUpsert) ensureNoTypeChange(value any) error {
|
||||
v, _ := value.(string)
|
||||
|
||||
if !form.collection.IsNew() && v != form.collection.Type {
|
||||
return validation.NewError("validation_collection_type_change", "Collection type cannot be changed.")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (form *CollectionUpsert) ensureNoFieldsTypeChange(value any) error {
|
||||
@@ -178,9 +204,17 @@ func (form *CollectionUpsert) ensureNoFieldsTypeChange(value any) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (form *CollectionUpsert) ensureExistingRelationCollectionId(value any) error {
|
||||
func (form *CollectionUpsert) checkRelationFields(value any) error {
|
||||
v, _ := value.(schema.Schema)
|
||||
|
||||
systemDisplayFields := schema.BaseModelFieldNames()
|
||||
systemDisplayFields = append(systemDisplayFields,
|
||||
schema.FieldNameUsername,
|
||||
schema.FieldNameEmail,
|
||||
schema.FieldNameEmailVisibility,
|
||||
schema.FieldNameVerified,
|
||||
)
|
||||
|
||||
for i, field := range v.Fields() {
|
||||
if field.Type != schema.FieldTypeRelation {
|
||||
continue
|
||||
@@ -191,12 +225,76 @@ func (form *CollectionUpsert) ensureExistingRelationCollectionId(value any) erro
|
||||
continue
|
||||
}
|
||||
|
||||
if _, err := form.config.TxDao.FindCollectionByNameOrId(options.CollectionId); err != nil {
|
||||
return validation.Errors{fmt.Sprint(i): validation.NewError(
|
||||
"validation_field_invalid_relation",
|
||||
"The relation collection doesn't exist.",
|
||||
)}
|
||||
collection, err := form.dao.FindCollectionByNameOrId(options.CollectionId)
|
||||
|
||||
// validate collectionId
|
||||
if err != nil || collection.Id != options.CollectionId {
|
||||
return validation.Errors{fmt.Sprint(i): validation.Errors{
|
||||
"options": validation.Errors{
|
||||
"collectionId": validation.NewError(
|
||||
"validation_field_invalid_relation",
|
||||
"The relation collection doesn't exist.",
|
||||
),
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
// validate displayFields (if any)
|
||||
for _, name := range options.DisplayFields {
|
||||
if collection.Schema.GetFieldByName(name) == nil && !list.ExistInSlice(name, systemDisplayFields) {
|
||||
return validation.Errors{fmt.Sprint(i): validation.Errors{
|
||||
"options": validation.Errors{
|
||||
"displayFields": validation.NewError(
|
||||
"validation_field_invalid_relation_displayFields",
|
||||
fmt.Sprintf("%q does not exist in the related %q collection.", name, collection.Name),
|
||||
),
|
||||
}},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (form *CollectionUpsert) ensureNoAuthFieldName(value any) error {
|
||||
v, _ := value.(schema.Schema)
|
||||
|
||||
if form.Type != models.CollectionTypeAuth {
|
||||
return nil // not an auth collection
|
||||
}
|
||||
|
||||
authFieldNames := schema.AuthFieldNames()
|
||||
// exclude the meta RecordUpsert form fields
|
||||
authFieldNames = append(authFieldNames, "password", "passwordConfirm", "oldPassword")
|
||||
|
||||
errs := validation.Errors{}
|
||||
for i, field := range v.Fields() {
|
||||
if list.ExistInSlice(field.Name, authFieldNames) {
|
||||
errs[fmt.Sprint(i)] = validation.Errors{
|
||||
"name": validation.NewError(
|
||||
"validation_reserved_auth_field_name",
|
||||
"The field name is reserved and cannot be used.",
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(errs) > 0 {
|
||||
return errs
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (form *CollectionUpsert) checkMinSchemaFields(value any) error {
|
||||
if form.Type == models.CollectionTypeAuth {
|
||||
return nil // auth collections doesn't require having additional schema fields
|
||||
}
|
||||
|
||||
v, ok := value.(schema.Schema)
|
||||
if !ok || len(v.Fields()) == 0 {
|
||||
return validation.ErrRequired
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -222,17 +320,49 @@ func (form *CollectionUpsert) ensureNoSystemFieldsChange(value any) error {
|
||||
|
||||
func (form *CollectionUpsert) checkRule(value any) error {
|
||||
v, _ := value.(*string)
|
||||
|
||||
if v == nil || *v == "" {
|
||||
return nil // nothing to check
|
||||
}
|
||||
|
||||
dummy := &models.Collection{Schema: form.Schema}
|
||||
r := resolvers.NewRecordFieldResolver(form.config.TxDao, dummy, nil)
|
||||
dummy := *form.collection
|
||||
dummy.Type = form.Type
|
||||
dummy.Schema = form.Schema
|
||||
dummy.System = form.System
|
||||
dummy.Options = form.Options
|
||||
|
||||
r := resolvers.NewRecordFieldResolver(form.dao, &dummy, nil, true)
|
||||
|
||||
_, err := search.FilterData(*v).BuildExpr(r)
|
||||
if err != nil {
|
||||
return validation.NewError("validation_collection_rule", "Invalid filter rule.")
|
||||
return validation.NewError("validation_invalid_rule", "Invalid filter rule.")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (form *CollectionUpsert) checkOptions(value any) error {
|
||||
v, _ := value.(types.JsonMap)
|
||||
|
||||
if form.Type == models.CollectionTypeAuth {
|
||||
raw, err := v.MarshalJSON()
|
||||
if err != nil {
|
||||
return validation.NewError("validation_invalid_options", "Invalid options.")
|
||||
}
|
||||
|
||||
options := models.CollectionAuthOptions{}
|
||||
if err := json.Unmarshal(raw, &options); err != nil {
|
||||
return validation.NewError("validation_invalid_options", "Invalid options.")
|
||||
}
|
||||
|
||||
// check the generic validations
|
||||
if err := options.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// additional form specific validations
|
||||
if err := form.checkRule(options.ManageRule); err != nil {
|
||||
return validation.Errors{"manageRule": err}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -244,12 +374,15 @@ func (form *CollectionUpsert) checkRule(value any) error {
|
||||
//
|
||||
// You can optionally provide a list of InterceptorFunc to further
|
||||
// modify the form behavior before persisting it.
|
||||
func (form *CollectionUpsert) Submit(interceptors ...InterceptorFunc) error {
|
||||
func (form *CollectionUpsert) Submit(interceptors ...InterceptorFunc[*models.Collection]) error {
|
||||
if err := form.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if form.collection.IsNew() {
|
||||
// type can be set only on create
|
||||
form.collection.Type = form.Type
|
||||
|
||||
// system flag can be set only on create
|
||||
form.collection.System = form.System
|
||||
|
||||
@@ -271,8 +404,9 @@ func (form *CollectionUpsert) Submit(interceptors ...InterceptorFunc) error {
|
||||
form.collection.CreateRule = form.CreateRule
|
||||
form.collection.UpdateRule = form.UpdateRule
|
||||
form.collection.DeleteRule = form.DeleteRule
|
||||
form.collection.SetOptions(form.Options)
|
||||
|
||||
return runInterceptors(func() error {
|
||||
return form.config.TxDao.SaveCollection(form.collection)
|
||||
return runInterceptors(form.collection, func(collection *models.Collection) error {
|
||||
return form.dao.SaveCollection(collection)
|
||||
}, interceptors...)
|
||||
}
|
||||
|
||||
+154
-185
@@ -14,35 +14,13 @@ import (
|
||||
"github.com/spf13/cast"
|
||||
)
|
||||
|
||||
func TestCollectionUpsertPanic1(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("The form did not panic")
|
||||
}
|
||||
}()
|
||||
|
||||
forms.NewCollectionUpsert(nil, nil)
|
||||
}
|
||||
|
||||
func TestCollectionUpsertPanic2(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("The form did not panic")
|
||||
}
|
||||
}()
|
||||
|
||||
forms.NewCollectionUpsert(app, nil)
|
||||
}
|
||||
|
||||
func TestNewCollectionUpsert(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
collection := &models.Collection{}
|
||||
collection.Name = "test"
|
||||
collection.Name = "test_name"
|
||||
collection.Type = "test_type"
|
||||
collection.System = true
|
||||
listRule := "testview"
|
||||
collection.ListRule = &listRule
|
||||
@@ -65,6 +43,10 @@ func TestNewCollectionUpsert(t *testing.T) {
|
||||
t.Errorf("Expected Name %q, got %q", collection.Name, form.Name)
|
||||
}
|
||||
|
||||
if form.Type != collection.Type {
|
||||
t.Errorf("Expected Type %q, got %q", collection.Type, form.Type)
|
||||
}
|
||||
|
||||
if form.System != collection.System {
|
||||
t.Errorf("Expected System %v, got %v", collection.System, form.System)
|
||||
}
|
||||
@@ -104,95 +86,25 @@ func TestNewCollectionUpsert(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectionUpsertValidate(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
scenarios := []struct {
|
||||
jsonData string
|
||||
expectedErrors []string
|
||||
}{
|
||||
{"{}", []string{"name", "schema"}},
|
||||
{
|
||||
`{
|
||||
"name": "test ?!@#$",
|
||||
"system": true,
|
||||
"schema": [
|
||||
{"name":"","type":"text"}
|
||||
],
|
||||
"listRule": "missing = '123'",
|
||||
"viewRule": "missing = '123'",
|
||||
"createRule": "missing = '123'",
|
||||
"updateRule": "missing = '123'",
|
||||
"deleteRule": "missing = '123'"
|
||||
}`,
|
||||
[]string{"name", "schema", "listRule", "viewRule", "createRule", "updateRule", "deleteRule"},
|
||||
},
|
||||
{
|
||||
`{
|
||||
"name": "test",
|
||||
"system": true,
|
||||
"schema": [
|
||||
{"name":"test","type":"text"}
|
||||
],
|
||||
"listRule": "test='123'",
|
||||
"viewRule": "test='123'",
|
||||
"createRule": "test='123'",
|
||||
"updateRule": "test='123'",
|
||||
"deleteRule": "test='123'"
|
||||
}`,
|
||||
[]string{},
|
||||
},
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
form := forms.NewCollectionUpsert(app, &models.Collection{})
|
||||
|
||||
// load data
|
||||
loadErr := json.Unmarshal([]byte(s.jsonData), form)
|
||||
if loadErr != nil {
|
||||
t.Errorf("(%d) Failed to load form data: %v", i, loadErr)
|
||||
continue
|
||||
}
|
||||
|
||||
// parse errors
|
||||
result := form.Validate()
|
||||
errs, ok := result.(validation.Errors)
|
||||
if !ok && result != nil {
|
||||
t.Errorf("(%d) Failed to parse errors %v", i, result)
|
||||
continue
|
||||
}
|
||||
|
||||
// check errors
|
||||
if len(errs) > len(s.expectedErrors) {
|
||||
t.Errorf("(%d) Expected error keys %v, got %v", i, s.expectedErrors, errs)
|
||||
}
|
||||
for _, k := range s.expectedErrors {
|
||||
if _, ok := errs[k]; !ok {
|
||||
t.Errorf("(%d) Missing expected error key %q in %v", i, k, errs)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectionUpsertSubmit(t *testing.T) {
|
||||
func TestCollectionUpsertValidateAndSubmit(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
scenarios := []struct {
|
||||
testName string
|
||||
existingName string
|
||||
jsonData string
|
||||
expectedErrors []string
|
||||
}{
|
||||
// empty create
|
||||
{"", "{}", []string{"name", "schema"}},
|
||||
// empty update
|
||||
{"demo", "{}", []string{}},
|
||||
// create failure
|
||||
{"empty create (base)", "", "{}", []string{"name", "schema"}},
|
||||
{"empty create (auth)", "", `{"type":"auth"}`, []string{"name"}},
|
||||
{"empty update", "demo2", "{}", []string{}},
|
||||
{
|
||||
"create failure",
|
||||
"",
|
||||
`{
|
||||
"name": "test ?!@#$",
|
||||
"type": "invalid",
|
||||
"system": true,
|
||||
"schema": [
|
||||
{"name":"","type":"text"}
|
||||
@@ -203,13 +115,13 @@ func TestCollectionUpsertSubmit(t *testing.T) {
|
||||
"updateRule": "missing = '123'",
|
||||
"deleteRule": "missing = '123'"
|
||||
}`,
|
||||
[]string{"name", "schema", "listRule", "viewRule", "createRule", "updateRule", "deleteRule"},
|
||||
[]string{"name", "type", "schema", "listRule", "viewRule", "createRule", "updateRule", "deleteRule"},
|
||||
},
|
||||
// create failure - existing name
|
||||
{
|
||||
"create failure - existing name",
|
||||
"",
|
||||
`{
|
||||
"name": "demo",
|
||||
"name": "demo1",
|
||||
"system": true,
|
||||
"schema": [
|
||||
{"name":"test","type":"text"}
|
||||
@@ -222,19 +134,19 @@ func TestCollectionUpsertSubmit(t *testing.T) {
|
||||
}`,
|
||||
[]string{"name"},
|
||||
},
|
||||
// create failure - existing internal table
|
||||
{
|
||||
"create failure - existing internal table",
|
||||
"",
|
||||
`{
|
||||
"name": "_users",
|
||||
"name": "_admins",
|
||||
"schema": [
|
||||
{"name":"test","type":"text"}
|
||||
]
|
||||
}`,
|
||||
[]string{"name"},
|
||||
},
|
||||
// create failure - name starting with underscore
|
||||
{
|
||||
"create failure - name starting with underscore",
|
||||
"",
|
||||
`{
|
||||
"name": "_test_new",
|
||||
@@ -244,8 +156,8 @@ func TestCollectionUpsertSubmit(t *testing.T) {
|
||||
}`,
|
||||
[]string{"name"},
|
||||
},
|
||||
// create failure - duplicated field names (case insensitive)
|
||||
{
|
||||
"create failure - duplicated field names (case insensitive)",
|
||||
"",
|
||||
`{
|
||||
"name": "test_new",
|
||||
@@ -256,26 +168,67 @@ func TestCollectionUpsertSubmit(t *testing.T) {
|
||||
}`,
|
||||
[]string{"schema"},
|
||||
},
|
||||
// create success
|
||||
{
|
||||
"create failure - missing relation display field",
|
||||
"",
|
||||
`{
|
||||
"name": "test_new",
|
||||
"type": "base",
|
||||
"schema": [
|
||||
{
|
||||
"name":"test",
|
||||
"type":"relation",
|
||||
"options":{
|
||||
"collectionId":"wsmn24bux7wo113",
|
||||
"displayFields":["text", "missing"]
|
||||
}
|
||||
}
|
||||
]
|
||||
}`,
|
||||
[]string{"schema"},
|
||||
},
|
||||
{
|
||||
"create failure - check type options validators",
|
||||
"",
|
||||
`{
|
||||
"name": "test_new",
|
||||
"type": "auth",
|
||||
"schema": [
|
||||
{"name":"test","type":"text"}
|
||||
],
|
||||
"options": { "minPasswordLength": 3 }
|
||||
}`,
|
||||
[]string{"options"},
|
||||
},
|
||||
{
|
||||
"create success",
|
||||
"",
|
||||
`{
|
||||
"name": "test_new",
|
||||
"type": "auth",
|
||||
"system": true,
|
||||
"schema": [
|
||||
{"id":"a123456","name":"test1","type":"text"},
|
||||
{"id":"b123456","name":"test2","type":"email"}
|
||||
{"id":"b123456","name":"test2","type":"email"},
|
||||
{
|
||||
"name":"test3",
|
||||
"type":"relation",
|
||||
"options":{
|
||||
"collectionId":"v851q4r790rhknl",
|
||||
"displayFields":["name","id","created","updated","username","email","emailVisibility","verified"]
|
||||
}
|
||||
}
|
||||
],
|
||||
"listRule": "test1='123'",
|
||||
"viewRule": "test1='123'",
|
||||
"createRule": "test1='123'",
|
||||
"updateRule": "test1='123'",
|
||||
"deleteRule": "test1='123'"
|
||||
"listRule": "test1='123' && verified = true",
|
||||
"viewRule": "test1='123' && emailVisibility = true",
|
||||
"createRule": "test1='123' && email != ''",
|
||||
"updateRule": "test1='123' && username != ''",
|
||||
"deleteRule": "test1='123' && id != ''"
|
||||
}`,
|
||||
[]string{},
|
||||
},
|
||||
// update failure - changing field type
|
||||
{
|
||||
"update failure - changing field type",
|
||||
"test_new",
|
||||
`{
|
||||
"schema": [
|
||||
@@ -285,8 +238,8 @@ func TestCollectionUpsertSubmit(t *testing.T) {
|
||||
}`,
|
||||
[]string{"schema"},
|
||||
},
|
||||
// update success - rename fields to existing field names (aka. reusing field names)
|
||||
{
|
||||
"update success - rename fields to existing field names (aka. reusing field names)",
|
||||
"test_new",
|
||||
`{
|
||||
"schema": [
|
||||
@@ -296,34 +249,43 @@ func TestCollectionUpsertSubmit(t *testing.T) {
|
||||
}`,
|
||||
[]string{},
|
||||
},
|
||||
// update failure - existing name
|
||||
{
|
||||
"demo",
|
||||
`{"name": "demo2"}`,
|
||||
"update failure - existing name",
|
||||
"demo2",
|
||||
`{"name": "demo3"}`,
|
||||
[]string{"name"},
|
||||
},
|
||||
// update failure - changing system collection
|
||||
{
|
||||
models.ProfileCollectionName,
|
||||
"update failure - changing system collection",
|
||||
"nologin",
|
||||
`{
|
||||
"name": "update",
|
||||
"system": false,
|
||||
"schema": [
|
||||
{"id":"koih1lqx","name":"userId","type":"text"}
|
||||
{"id":"koih1lqx","name":"abc","type":"text"}
|
||||
],
|
||||
"listRule": "userId = '123'",
|
||||
"viewRule": "userId = '123'",
|
||||
"createRule": "userId = '123'",
|
||||
"updateRule": "userId = '123'",
|
||||
"deleteRule": "userId = '123'"
|
||||
"listRule": "abc = '123'",
|
||||
"viewRule": "abc = '123'",
|
||||
"createRule": "abc = '123'",
|
||||
"updateRule": "abc = '123'",
|
||||
"deleteRule": "abc = '123'"
|
||||
}`,
|
||||
[]string{"name", "system", "schema"},
|
||||
[]string{"name", "system"},
|
||||
},
|
||||
// update failure - all fields
|
||||
{
|
||||
"demo",
|
||||
"update failure - changing collection type",
|
||||
"demo3",
|
||||
`{
|
||||
"type": "auth"
|
||||
}`,
|
||||
[]string{"type"},
|
||||
},
|
||||
{
|
||||
"update failure - all fields",
|
||||
"demo2",
|
||||
`{
|
||||
"name": "test ?!@#$",
|
||||
"type": "invalid",
|
||||
"system": true,
|
||||
"schema": [
|
||||
{"name":"","type":"text"}
|
||||
@@ -332,29 +294,32 @@ func TestCollectionUpsertSubmit(t *testing.T) {
|
||||
"viewRule": "missing = '123'",
|
||||
"createRule": "missing = '123'",
|
||||
"updateRule": "missing = '123'",
|
||||
"deleteRule": "missing = '123'"
|
||||
"deleteRule": "missing = '123'",
|
||||
"options": {"test": 123}
|
||||
}`,
|
||||
[]string{"name", "system", "schema", "listRule", "viewRule", "createRule", "updateRule", "deleteRule"},
|
||||
[]string{"name", "type", "system", "schema", "listRule", "viewRule", "createRule", "updateRule", "deleteRule"},
|
||||
},
|
||||
// update success - update all fields
|
||||
{
|
||||
"demo",
|
||||
"update success - update all fields",
|
||||
"clients",
|
||||
`{
|
||||
"name": "demo_update",
|
||||
"type": "auth",
|
||||
"schema": [
|
||||
{"id":"_2hlxbmp","name":"test","type":"text"}
|
||||
],
|
||||
"listRule": "test='123'",
|
||||
"viewRule": "test='123'",
|
||||
"createRule": "test='123'",
|
||||
"updateRule": "test='123'",
|
||||
"deleteRule": "test='123'"
|
||||
"listRule": "test='123' && verified = true",
|
||||
"viewRule": "test='123' && emailVisibility = true",
|
||||
"createRule": "test='123' && email != ''",
|
||||
"updateRule": "test='123' && username != ''",
|
||||
"deleteRule": "test='123' && id != ''",
|
||||
"options": {"minPasswordLength": 10}
|
||||
}`,
|
||||
[]string{},
|
||||
},
|
||||
// update failure - rename the schema field of the last updated collection
|
||||
// (fail due to filters old field references)
|
||||
{
|
||||
"update failure - rename the schema field of the last updated collection",
|
||||
"demo_update",
|
||||
`{
|
||||
"schema": [
|
||||
@@ -363,9 +328,9 @@ func TestCollectionUpsertSubmit(t *testing.T) {
|
||||
}`,
|
||||
[]string{"listRule", "viewRule", "createRule", "updateRule", "deleteRule"},
|
||||
},
|
||||
// update success - rename the schema field of the last updated collection
|
||||
// (cleared filter references)
|
||||
{
|
||||
"update success - rename the schema field of the last updated collection",
|
||||
"demo_update",
|
||||
`{
|
||||
"schema": [
|
||||
@@ -379,21 +344,21 @@ func TestCollectionUpsertSubmit(t *testing.T) {
|
||||
}`,
|
||||
[]string{},
|
||||
},
|
||||
// update success - system collection
|
||||
{
|
||||
models.ProfileCollectionName,
|
||||
"update success - system collection",
|
||||
"nologin",
|
||||
`{
|
||||
"listRule": "userId='123'",
|
||||
"viewRule": "userId='123'",
|
||||
"createRule": "userId='123'",
|
||||
"updateRule": "userId='123'",
|
||||
"deleteRule": "userId='123'"
|
||||
"listRule": "name='123'",
|
||||
"viewRule": "name='123'",
|
||||
"createRule": "name='123'",
|
||||
"updateRule": "name='123'",
|
||||
"deleteRule": "name='123'"
|
||||
}`,
|
||||
[]string{},
|
||||
},
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
for _, s := range scenarios {
|
||||
collection := &models.Collection{}
|
||||
if s.existingName != "" {
|
||||
var err error
|
||||
@@ -408,15 +373,15 @@ func TestCollectionUpsertSubmit(t *testing.T) {
|
||||
// load data
|
||||
loadErr := json.Unmarshal([]byte(s.jsonData), form)
|
||||
if loadErr != nil {
|
||||
t.Errorf("(%d) Failed to load form data: %v", i, loadErr)
|
||||
t.Errorf("[%s] Failed to load form data: %v", s.testName, loadErr)
|
||||
continue
|
||||
}
|
||||
|
||||
interceptorCalls := 0
|
||||
interceptor := func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
|
||||
return func() error {
|
||||
interceptor := func(next forms.InterceptorNextFunc[*models.Collection]) forms.InterceptorNextFunc[*models.Collection] {
|
||||
return func(c *models.Collection) error {
|
||||
interceptorCalls++
|
||||
return next()
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -424,26 +389,26 @@ func TestCollectionUpsertSubmit(t *testing.T) {
|
||||
result := form.Submit(interceptor)
|
||||
errs, ok := result.(validation.Errors)
|
||||
if !ok && result != nil {
|
||||
t.Errorf("(%d) Failed to parse errors %v", i, result)
|
||||
t.Errorf("[%s] Failed to parse errors %v", s.testName, result)
|
||||
continue
|
||||
}
|
||||
|
||||
// check interceptor calls
|
||||
expectInterceptorCall := 1
|
||||
expectInterceptorCalls := 1
|
||||
if len(s.expectedErrors) > 0 {
|
||||
expectInterceptorCall = 0
|
||||
expectInterceptorCalls = 0
|
||||
}
|
||||
if interceptorCalls != expectInterceptorCall {
|
||||
t.Errorf("(%d) Expected interceptor to be called %d, got %d", i, expectInterceptorCall, interceptorCalls)
|
||||
if interceptorCalls != expectInterceptorCalls {
|
||||
t.Errorf("[%s] Expected interceptor to be called %d, got %d", s.testName, expectInterceptorCalls, interceptorCalls)
|
||||
}
|
||||
|
||||
// check errors
|
||||
if len(errs) > len(s.expectedErrors) {
|
||||
t.Errorf("(%d) Expected error keys %v, got %v", i, s.expectedErrors, errs)
|
||||
t.Errorf("[%s] Expected error keys %v, got %v", s.testName, s.expectedErrors, errs)
|
||||
}
|
||||
for _, k := range s.expectedErrors {
|
||||
if _, ok := errs[k]; !ok {
|
||||
t.Errorf("(%d) Missing expected error key %q in %v", i, k, errs)
|
||||
t.Errorf("[%s] Missing expected error key %q in %v", s.testName, k, errs)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -453,42 +418,46 @@ func TestCollectionUpsertSubmit(t *testing.T) {
|
||||
|
||||
collection, _ = app.Dao().FindCollectionByNameOrId(form.Name)
|
||||
if collection == nil {
|
||||
t.Errorf("(%d) Expected to find collection %q, got nil", i, form.Name)
|
||||
t.Errorf("[%s] Expected to find collection %q, got nil", s.testName, form.Name)
|
||||
continue
|
||||
}
|
||||
|
||||
if form.Name != collection.Name {
|
||||
t.Errorf("(%d) Expected Name %q, got %q", i, collection.Name, form.Name)
|
||||
t.Errorf("[%s] Expected Name %q, got %q", s.testName, collection.Name, form.Name)
|
||||
}
|
||||
|
||||
if form.Type != collection.Type {
|
||||
t.Errorf("[%s] Expected Type %q, got %q", s.testName, collection.Type, form.Type)
|
||||
}
|
||||
|
||||
if form.System != collection.System {
|
||||
t.Errorf("(%d) Expected System %v, got %v", i, collection.System, form.System)
|
||||
t.Errorf("[%s] Expected System %v, got %v", s.testName, collection.System, form.System)
|
||||
}
|
||||
|
||||
if cast.ToString(form.ListRule) != cast.ToString(collection.ListRule) {
|
||||
t.Errorf("(%d) Expected ListRule %v, got %v", i, collection.ListRule, form.ListRule)
|
||||
t.Errorf("[%s] Expected ListRule %v, got %v", s.testName, collection.ListRule, form.ListRule)
|
||||
}
|
||||
|
||||
if cast.ToString(form.ViewRule) != cast.ToString(collection.ViewRule) {
|
||||
t.Errorf("(%d) Expected ViewRule %v, got %v", i, collection.ViewRule, form.ViewRule)
|
||||
t.Errorf("[%s] Expected ViewRule %v, got %v", s.testName, collection.ViewRule, form.ViewRule)
|
||||
}
|
||||
|
||||
if cast.ToString(form.CreateRule) != cast.ToString(collection.CreateRule) {
|
||||
t.Errorf("(%d) Expected CreateRule %v, got %v", i, collection.CreateRule, form.CreateRule)
|
||||
t.Errorf("[%s] Expected CreateRule %v, got %v", s.testName, collection.CreateRule, form.CreateRule)
|
||||
}
|
||||
|
||||
if cast.ToString(form.UpdateRule) != cast.ToString(collection.UpdateRule) {
|
||||
t.Errorf("(%d) Expected UpdateRule %v, got %v", i, collection.UpdateRule, form.UpdateRule)
|
||||
t.Errorf("[%s] Expected UpdateRule %v, got %v", s.testName, collection.UpdateRule, form.UpdateRule)
|
||||
}
|
||||
|
||||
if cast.ToString(form.DeleteRule) != cast.ToString(collection.DeleteRule) {
|
||||
t.Errorf("(%d) Expected DeleteRule %v, got %v", i, collection.DeleteRule, form.DeleteRule)
|
||||
t.Errorf("[%s] Expected DeleteRule %v, got %v", s.testName, collection.DeleteRule, form.DeleteRule)
|
||||
}
|
||||
|
||||
formSchema, _ := form.Schema.MarshalJSON()
|
||||
collectionSchema, _ := collection.Schema.MarshalJSON()
|
||||
if string(formSchema) != string(collectionSchema) {
|
||||
t.Errorf("(%d) Expected Schema %v, got %v", i, string(collectionSchema), string(formSchema))
|
||||
t.Errorf("[%s] Expected Schema %v, got %v", s.testName, string(collectionSchema), string(formSchema))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -497,7 +466,7 @@ func TestCollectionUpsertSubmitInterceptors(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
collection, err := app.Dao().FindCollectionByNameOrId("demo")
|
||||
collection, err := app.Dao().FindCollectionByNameOrId("demo2")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -509,16 +478,16 @@ func TestCollectionUpsertSubmitInterceptors(t *testing.T) {
|
||||
interceptorCollectionName := ""
|
||||
|
||||
interceptor1Called := false
|
||||
interceptor1 := func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
|
||||
return func() error {
|
||||
interceptor1 := func(next forms.InterceptorNextFunc[*models.Collection]) forms.InterceptorNextFunc[*models.Collection] {
|
||||
return func(c *models.Collection) error {
|
||||
interceptor1Called = true
|
||||
return next()
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
|
||||
interceptor2Called := false
|
||||
interceptor2 := func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
|
||||
return func() error {
|
||||
interceptor2 := func(next forms.InterceptorNextFunc[*models.Collection]) forms.InterceptorNextFunc[*models.Collection] {
|
||||
return func(c *models.Collection) error {
|
||||
interceptorCollectionName = collection.Name // to check if the record was filled
|
||||
interceptor2Called = true
|
||||
return testErr
|
||||
@@ -547,14 +516,14 @@ func TestCollectionUpsertWithCustomId(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
existingCollection, err := app.Dao().FindCollectionByNameOrId("demo3")
|
||||
existingCollection, err := app.Dao().FindCollectionByNameOrId("demo2")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
newCollection := func() *models.Collection {
|
||||
return &models.Collection{
|
||||
Name: "c_" + security.RandomString(4),
|
||||
Name: "c_" + security.PseudorandomString(4),
|
||||
Schema: existingCollection.Schema,
|
||||
}
|
||||
}
|
||||
@@ -621,27 +590,27 @@ func TestCollectionUpsertWithCustomId(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
form := forms.NewCollectionUpsert(app, scenario.collection)
|
||||
for _, s := range scenarios {
|
||||
form := forms.NewCollectionUpsert(app, s.collection)
|
||||
|
||||
// load data
|
||||
loadErr := json.Unmarshal([]byte(scenario.jsonData), form)
|
||||
loadErr := json.Unmarshal([]byte(s.jsonData), form)
|
||||
if loadErr != nil {
|
||||
t.Errorf("[%s] Failed to load form data: %v", scenario.name, loadErr)
|
||||
t.Errorf("[%s] Failed to load form data: %v", s.name, loadErr)
|
||||
continue
|
||||
}
|
||||
|
||||
submitErr := form.Submit()
|
||||
hasErr := submitErr != nil
|
||||
|
||||
if hasErr != scenario.expectError {
|
||||
t.Errorf("[%s] Expected hasErr to be %v, got %v (%v)", scenario.name, scenario.expectError, hasErr, submitErr)
|
||||
if hasErr != s.expectError {
|
||||
t.Errorf("[%s] Expected hasErr to be %v, got %v (%v)", s.name, s.expectError, hasErr, submitErr)
|
||||
}
|
||||
|
||||
if !hasErr && form.Id != "" {
|
||||
_, err := app.Dao().FindCollectionByNameOrId(form.Id)
|
||||
if err != nil {
|
||||
t.Errorf("[%s] Expected to find record with id %s, got %v", scenario.name, form.Id, err)
|
||||
t.Errorf("[%s] Expected to find record with id %s, got %v", s.name, form.Id, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+23
-39
@@ -11,48 +11,31 @@ import (
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
)
|
||||
|
||||
// CollectionsImport specifies a form model to bulk import
|
||||
// CollectionsImport is a form model to bulk import
|
||||
// (create, replace and delete) collections from a user provided list.
|
||||
type CollectionsImport struct {
|
||||
config CollectionsImportConfig
|
||||
app core.App
|
||||
dao *daos.Dao
|
||||
|
||||
Collections []*models.Collection `form:"collections" json:"collections"`
|
||||
DeleteMissing bool `form:"deleteMissing" json:"deleteMissing"`
|
||||
}
|
||||
|
||||
// CollectionsImportConfig is the [CollectionsImport] factory initializer config.
|
||||
//
|
||||
// NB! App is a required struct member.
|
||||
type CollectionsImportConfig struct {
|
||||
App core.App
|
||||
TxDao *daos.Dao
|
||||
}
|
||||
|
||||
// NewCollectionsImport creates a new [CollectionsImport] form with
|
||||
// initializer config created from the provided [core.App] instance.
|
||||
// initialized with from the provided [core.App] instance.
|
||||
//
|
||||
// If you want to submit the form as part of another transaction, use
|
||||
// [NewCollectionsImportWithConfig] with explicitly set TxDao.
|
||||
// If you want to submit the form as part of a transaction,
|
||||
// you can change the default Dao via [SetDao()].
|
||||
func NewCollectionsImport(app core.App) *CollectionsImport {
|
||||
return NewCollectionsImportWithConfig(CollectionsImportConfig{
|
||||
App: app,
|
||||
})
|
||||
return &CollectionsImport{
|
||||
app: app,
|
||||
dao: app.Dao(),
|
||||
}
|
||||
}
|
||||
|
||||
// NewCollectionsImportWithConfig creates a new [CollectionsImport]
|
||||
// form with the provided config or panics on invalid configuration.
|
||||
func NewCollectionsImportWithConfig(config CollectionsImportConfig) *CollectionsImport {
|
||||
form := &CollectionsImport{config: config}
|
||||
|
||||
if form.config.App == nil {
|
||||
panic("Missing required config.App instance.")
|
||||
}
|
||||
|
||||
if form.config.TxDao == nil {
|
||||
form.config.TxDao = form.config.App.Dao()
|
||||
}
|
||||
|
||||
return form
|
||||
// SetDao replaces the default form Dao instance with the provided one.
|
||||
func (form *CollectionsImport) SetDao(dao *daos.Dao) {
|
||||
form.dao = dao
|
||||
}
|
||||
|
||||
// Validate makes the form validatable by implementing [validation.Validatable] interface.
|
||||
@@ -73,15 +56,15 @@ func (form *CollectionsImport) Validate() error {
|
||||
//
|
||||
// You can optionally provide a list of InterceptorFunc to further
|
||||
// modify the form behavior before persisting it.
|
||||
func (form *CollectionsImport) Submit(interceptors ...InterceptorFunc) error {
|
||||
func (form *CollectionsImport) Submit(interceptors ...InterceptorFunc[[]*models.Collection]) error {
|
||||
if err := form.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return runInterceptors(func() error {
|
||||
return form.config.TxDao.RunInTransaction(func(txDao *daos.Dao) error {
|
||||
return runInterceptors(form.Collections, func(collections []*models.Collection) error {
|
||||
return form.dao.RunInTransaction(func(txDao *daos.Dao) error {
|
||||
importErr := txDao.ImportCollections(
|
||||
form.Collections,
|
||||
collections,
|
||||
form.DeleteMissing,
|
||||
form.beforeRecordsSync,
|
||||
)
|
||||
@@ -95,7 +78,7 @@ func (form *CollectionsImport) Submit(interceptors ...InterceptorFunc) error {
|
||||
}
|
||||
|
||||
// generic/db failure
|
||||
if form.config.App.IsDebug() {
|
||||
if form.app.IsDebug() {
|
||||
log.Println("Internal import failure:", importErr)
|
||||
}
|
||||
return validation.Errors{"collections": validation.NewError(
|
||||
@@ -120,14 +103,14 @@ func (form *CollectionsImport) beforeRecordsSync(txDao *daos.Dao, mappedNew, map
|
||||
if upsertModel == nil {
|
||||
upsertModel = collection
|
||||
}
|
||||
upsertModel.MarkAsNotNew()
|
||||
|
||||
upsertForm := NewCollectionUpsertWithConfig(CollectionUpsertConfig{
|
||||
App: form.config.App,
|
||||
TxDao: txDao,
|
||||
}, upsertModel)
|
||||
upsertForm := NewCollectionUpsert(form.app, upsertModel)
|
||||
upsertForm.SetDao(txDao)
|
||||
|
||||
// load form fields with the refreshed collection state
|
||||
upsertForm.Id = collection.Id
|
||||
upsertForm.Type = collection.Type
|
||||
upsertForm.Name = collection.Name
|
||||
upsertForm.System = collection.System
|
||||
upsertForm.ListRule = collection.ListRule
|
||||
@@ -136,6 +119,7 @@ func (form *CollectionsImport) beforeRecordsSync(txDao *daos.Dao, mappedNew, map
|
||||
upsertForm.UpdateRule = collection.UpdateRule
|
||||
upsertForm.DeleteRule = collection.DeleteRule
|
||||
upsertForm.Schema = collection.Schema
|
||||
upsertForm.Options = collection.Options
|
||||
|
||||
if err := upsertForm.Validate(); err != nil {
|
||||
// serialize the validation error(s)
|
||||
|
||||
@@ -10,16 +10,6 @@ import (
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
)
|
||||
|
||||
func TestCollectionsImportPanic(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("The form did not panic")
|
||||
}
|
||||
}()
|
||||
|
||||
forms.NewCollectionsImport(nil)
|
||||
}
|
||||
|
||||
func TestCollectionsImportValidate(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
@@ -62,7 +52,7 @@ func TestCollectionsImportSubmit(t *testing.T) {
|
||||
"collections": []
|
||||
}`,
|
||||
expectError: true,
|
||||
expectCollectionsCount: 5,
|
||||
expectCollectionsCount: 8,
|
||||
expectEvents: nil,
|
||||
},
|
||||
{
|
||||
@@ -92,7 +82,26 @@ func TestCollectionsImportSubmit(t *testing.T) {
|
||||
]
|
||||
}`,
|
||||
expectError: true,
|
||||
expectCollectionsCount: 5,
|
||||
expectCollectionsCount: 8,
|
||||
expectEvents: map[string]int{
|
||||
"OnModelBeforeCreate": 2,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "test empty base collection schema",
|
||||
jsonData: `{
|
||||
"collections": [
|
||||
{
|
||||
"name": "import1"
|
||||
},
|
||||
{
|
||||
"name": "import2",
|
||||
"type": "auth"
|
||||
}
|
||||
]
|
||||
}`,
|
||||
expectError: true,
|
||||
expectCollectionsCount: 8,
|
||||
expectEvents: map[string]int{
|
||||
"OnModelBeforeCreate": 2,
|
||||
},
|
||||
@@ -120,14 +129,18 @@ func TestCollectionsImportSubmit(t *testing.T) {
|
||||
"type":"bool"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "import3",
|
||||
"type": "auth"
|
||||
}
|
||||
]
|
||||
}`,
|
||||
expectError: false,
|
||||
expectCollectionsCount: 7,
|
||||
expectCollectionsCount: 11,
|
||||
expectEvents: map[string]int{
|
||||
"OnModelBeforeCreate": 2,
|
||||
"OnModelAfterCreate": 2,
|
||||
"OnModelBeforeCreate": 3,
|
||||
"OnModelAfterCreate": 3,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -147,7 +160,7 @@ func TestCollectionsImportSubmit(t *testing.T) {
|
||||
]
|
||||
}`,
|
||||
expectError: true,
|
||||
expectCollectionsCount: 5,
|
||||
expectCollectionsCount: 8,
|
||||
expectEvents: map[string]int{
|
||||
"OnModelBeforeCreate": 1,
|
||||
},
|
||||
@@ -158,8 +171,8 @@ func TestCollectionsImportSubmit(t *testing.T) {
|
||||
"deleteMissing": true,
|
||||
"collections": [
|
||||
{
|
||||
"id":"3f2888f8-075d-49fe-9d09-ea7e951000dc",
|
||||
"name":"demo",
|
||||
"id":"sz5l5z67tg7gku0",
|
||||
"name":"demo2",
|
||||
"schema":[
|
||||
{
|
||||
"id":"_2hlxbmp",
|
||||
@@ -189,19 +202,22 @@ func TestCollectionsImportSubmit(t *testing.T) {
|
||||
]
|
||||
}`,
|
||||
expectError: true,
|
||||
expectCollectionsCount: 5,
|
||||
expectCollectionsCount: 8,
|
||||
expectEvents: map[string]int{
|
||||
"OnModelBeforeDelete": 5,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "modified + new collection",
|
||||
jsonData: `{
|
||||
"collections": [
|
||||
{
|
||||
"id":"3f2888f8-075d-49fe-9d09-ea7e951000dc",
|
||||
"name":"demo",
|
||||
"id":"sz5l5z67tg7gku0",
|
||||
"name":"demo2",
|
||||
"schema":[
|
||||
{
|
||||
"id":"_2hlxbmp",
|
||||
"name":"title",
|
||||
"name":"title_new",
|
||||
"type":"text",
|
||||
"system":false,
|
||||
"required":true,
|
||||
@@ -237,7 +253,7 @@ func TestCollectionsImportSubmit(t *testing.T) {
|
||||
]
|
||||
}`,
|
||||
expectError: false,
|
||||
expectCollectionsCount: 7,
|
||||
expectCollectionsCount: 10,
|
||||
expectEvents: map[string]int{
|
||||
"OnModelBeforeUpdate": 1,
|
||||
"OnModelAfterUpdate": 1,
|
||||
@@ -251,45 +267,44 @@ func TestCollectionsImportSubmit(t *testing.T) {
|
||||
"deleteMissing": true,
|
||||
"collections": [
|
||||
{
|
||||
"id":"abe78266-fd4d-4aea-962d-8c0138ac522b",
|
||||
"name":"profiles",
|
||||
"system":true,
|
||||
"listRule":"userId = @request.user.id",
|
||||
"viewRule":"created > 'test_change'",
|
||||
"createRule":"userId = @request.user.id",
|
||||
"updateRule":"userId = @request.user.id",
|
||||
"deleteRule":"userId = @request.user.id",
|
||||
"schema":[
|
||||
"id": "kpv709sk2lqbqk8",
|
||||
"system": true,
|
||||
"name": "nologin",
|
||||
"type": "auth",
|
||||
"options": {
|
||||
"allowEmailAuth": false,
|
||||
"allowOAuth2Auth": false,
|
||||
"allowUsernameAuth": false,
|
||||
"exceptEmailDomains": [],
|
||||
"manageRule": "@request.auth.collectionName = 'users'",
|
||||
"minPasswordLength": 8,
|
||||
"onlyEmailDomains": [],
|
||||
"requireEmail": true
|
||||
},
|
||||
"listRule": "",
|
||||
"viewRule": "",
|
||||
"createRule": "",
|
||||
"updateRule": "",
|
||||
"deleteRule": "",
|
||||
"schema": [
|
||||
{
|
||||
"id":"koih1lqx",
|
||||
"name":"userId",
|
||||
"type":"user",
|
||||
"system":true,
|
||||
"required":true,
|
||||
"unique":true,
|
||||
"options":{
|
||||
"maxSelect":1,
|
||||
"cascadeDelete":true
|
||||
}
|
||||
},
|
||||
{
|
||||
"id":"69ycbg3q",
|
||||
"name":"rel",
|
||||
"type":"relation",
|
||||
"system":false,
|
||||
"required":false,
|
||||
"unique":false,
|
||||
"options":{
|
||||
"maxSelect":2,
|
||||
"collectionId":"abe78266-fd4d-4aea-962d-8c0138ac522b",
|
||||
"cascadeDelete":false
|
||||
"id": "x8zzktwe",
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"system": false,
|
||||
"required": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"min": null,
|
||||
"max": null,
|
||||
"pattern": ""
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id":"3f2888f8-075d-49fe-9d09-ea7e951000dc",
|
||||
"name":"demo",
|
||||
"id":"sz5l5z67tg7gku0",
|
||||
"name":"demo2",
|
||||
"schema":[
|
||||
{
|
||||
"id":"_2hlxbmp",
|
||||
@@ -308,7 +323,7 @@ func TestCollectionsImportSubmit(t *testing.T) {
|
||||
},
|
||||
{
|
||||
"id": "test_deleted_collection_name_reuse",
|
||||
"name": "demo2",
|
||||
"name": "demo1",
|
||||
"schema": [
|
||||
{
|
||||
"id":"fz6iql2m",
|
||||
@@ -326,8 +341,8 @@ func TestCollectionsImportSubmit(t *testing.T) {
|
||||
"OnModelAfterUpdate": 2,
|
||||
"OnModelBeforeCreate": 1,
|
||||
"OnModelAfterCreate": 1,
|
||||
"OnModelBeforeDelete": 3,
|
||||
"OnModelAfterDelete": 3,
|
||||
"OnModelBeforeDelete": 6,
|
||||
"OnModelAfterDelete": 6,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -389,16 +404,16 @@ func TestCollectionsImportSubmitInterceptors(t *testing.T) {
|
||||
testErr := errors.New("test_error")
|
||||
|
||||
interceptor1Called := false
|
||||
interceptor1 := func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
|
||||
return func() error {
|
||||
interceptor1 := func(next forms.InterceptorNextFunc[[]*models.Collection]) forms.InterceptorNextFunc[[]*models.Collection] {
|
||||
return func(imports []*models.Collection) error {
|
||||
interceptor1Called = true
|
||||
return next()
|
||||
return next(imports)
|
||||
}
|
||||
}
|
||||
|
||||
interceptor2Called := false
|
||||
interceptor2 := func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
|
||||
return func() error {
|
||||
interceptor2 := func(next forms.InterceptorNextFunc[[]*models.Collection]) forms.InterceptorNextFunc[[]*models.Collection] {
|
||||
return func(imports []*models.Collection) error {
|
||||
interceptor2Called = true
|
||||
return testErr
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
)
|
||||
|
||||
// RealtimeSubscribe specifies a RealtimeSubscribe request form.
|
||||
// RealtimeSubscribe is a realtime subscriptions request form.
|
||||
type RealtimeSubscribe struct {
|
||||
ClientId string `form:"clientId" json:"clientId"`
|
||||
Subscriptions []string `form:"subscriptions" json:"subscriptions"`
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
package forms
|
||||
|
||||
import (
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/models/schema"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
)
|
||||
|
||||
// RecordEmailChangeConfirm is an auth record email change confirmation form.
|
||||
type RecordEmailChangeConfirm struct {
|
||||
app core.App
|
||||
dao *daos.Dao
|
||||
collection *models.Collection
|
||||
|
||||
Token string `form:"token" json:"token"`
|
||||
Password string `form:"password" json:"password"`
|
||||
}
|
||||
|
||||
// NewRecordEmailChangeConfirm creates a new [RecordEmailChangeConfirm] form
|
||||
// initialized with from the provided [core.App] and [models.Collection] instances.
|
||||
//
|
||||
// If you want to submit the form as part of a transaction,
|
||||
// you can change the default Dao via [SetDao()].
|
||||
func NewRecordEmailChangeConfirm(app core.App, collection *models.Collection) *RecordEmailChangeConfirm {
|
||||
return &RecordEmailChangeConfirm{
|
||||
app: app,
|
||||
dao: app.Dao(),
|
||||
collection: collection,
|
||||
}
|
||||
}
|
||||
|
||||
// SetDao replaces the default form Dao instance with the provided one.
|
||||
func (form *RecordEmailChangeConfirm) SetDao(dao *daos.Dao) {
|
||||
form.dao = dao
|
||||
}
|
||||
|
||||
// Validate makes the form validatable by implementing [validation.Validatable] interface.
|
||||
func (form *RecordEmailChangeConfirm) Validate() error {
|
||||
return validation.ValidateStruct(form,
|
||||
validation.Field(
|
||||
&form.Token,
|
||||
validation.Required,
|
||||
validation.By(form.checkToken),
|
||||
),
|
||||
validation.Field(
|
||||
&form.Password,
|
||||
validation.Required,
|
||||
validation.Length(1, 100),
|
||||
validation.By(form.checkPassword),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func (form *RecordEmailChangeConfirm) checkToken(value any) error {
|
||||
v, _ := value.(string)
|
||||
if v == "" {
|
||||
return nil // nothing to check
|
||||
}
|
||||
|
||||
authRecord, _, err := form.parseToken(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if authRecord.Collection().Id != form.collection.Id {
|
||||
return validation.NewError("validation_token_collection_mismatch", "The provided token is for different auth collection.")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (form *RecordEmailChangeConfirm) checkPassword(value any) error {
|
||||
v, _ := value.(string)
|
||||
if v == "" {
|
||||
return nil // nothing to check
|
||||
}
|
||||
|
||||
authRecord, _, _ := form.parseToken(form.Token)
|
||||
if authRecord == nil || !authRecord.ValidatePassword(v) {
|
||||
return validation.NewError("validation_invalid_password", "Missing or invalid auth record password.")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (form *RecordEmailChangeConfirm) parseToken(token string) (*models.Record, string, error) {
|
||||
// check token payload
|
||||
claims, _ := security.ParseUnverifiedJWT(token)
|
||||
newEmail, _ := claims["newEmail"].(string)
|
||||
if newEmail == "" {
|
||||
return nil, "", validation.NewError("validation_invalid_token_payload", "Invalid token payload - newEmail must be set.")
|
||||
}
|
||||
|
||||
// ensure that there aren't other users with the new email
|
||||
if !form.dao.IsRecordValueUnique(form.collection.Id, schema.FieldNameEmail, newEmail) {
|
||||
return nil, "", validation.NewError("validation_existing_token_email", "The new email address is already registered: "+newEmail)
|
||||
}
|
||||
|
||||
// verify that the token is not expired and its signature is valid
|
||||
authRecord, err := form.dao.FindAuthRecordByToken(
|
||||
token,
|
||||
form.app.Settings().RecordEmailChangeToken.Secret,
|
||||
)
|
||||
if err != nil || authRecord == nil {
|
||||
return nil, "", validation.NewError("validation_invalid_token", "Invalid or expired token.")
|
||||
}
|
||||
|
||||
return authRecord, newEmail, nil
|
||||
}
|
||||
|
||||
// Submit validates and submits the auth record email change confirmation form.
|
||||
// On success returns the updated auth record associated to `form.Token`.
|
||||
//
|
||||
// You can optionally provide a list of InterceptorFunc to
|
||||
// further modify the form behavior before persisting it.
|
||||
func (form *RecordEmailChangeConfirm) Submit(interceptors ...InterceptorFunc[*models.Record]) (*models.Record, error) {
|
||||
if err := form.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
authRecord, newEmail, err := form.parseToken(form.Token)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
authRecord.SetEmail(newEmail)
|
||||
authRecord.SetVerified(true)
|
||||
authRecord.RefreshTokenKey() // invalidate old tokens
|
||||
|
||||
interceptorsErr := runInterceptors(authRecord, func(m *models.Record) error {
|
||||
authRecord = m
|
||||
return form.dao.SaveRecord(m)
|
||||
}, interceptors...)
|
||||
|
||||
if interceptorsErr != nil {
|
||||
return nil, interceptorsErr
|
||||
}
|
||||
|
||||
return authRecord, nil
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
package forms_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/pocketbase/pocketbase/forms"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
)
|
||||
|
||||
func TestRecordEmailChangeConfirmValidateAndSubmit(t *testing.T) {
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
|
||||
authCollection, err := testApp.Dao().FindCollectionByNameOrId("users")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
scenarios := []struct {
|
||||
jsonData string
|
||||
expectedErrors []string
|
||||
}{
|
||||
// empty payload
|
||||
{"{}", []string{"token", "password"}},
|
||||
// empty data
|
||||
{
|
||||
`{"token": "", "password": ""}`,
|
||||
[]string{"token", "password"},
|
||||
},
|
||||
// invalid token payload
|
||||
{
|
||||
`{
|
||||
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.quDgaCi2rGTRx3qO06CrFvHdeCua_5J7CCVWSaFhkus",
|
||||
"password": "123456"
|
||||
}`,
|
||||
[]string{"token", "password"},
|
||||
},
|
||||
// expired token
|
||||
{
|
||||
`{
|
||||
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwibmV3RW1haWwiOiJ0ZXN0X25ld0BleGFtcGxlLmNvbSIsImV4cCI6MTYwOTQ1NTY2MX0.n1OJXJEACMNPT9aMTO48cVJexIiZEtHsz4UNBIfMcf4",
|
||||
"password": "123456"
|
||||
}`,
|
||||
[]string{"token", "password"},
|
||||
},
|
||||
// existing new email
|
||||
{
|
||||
`{
|
||||
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwibmV3RW1haWwiOiJ0ZXN0MkBleGFtcGxlLmNvbSIsImV4cCI6MjIwODk4NTI2MX0.Q_o6zpc2URggTU0mWv2CS0rIPbQhFdmrjZ-ASwHh1Ww",
|
||||
"password": "1234567890"
|
||||
}`,
|
||||
[]string{"token", "password"},
|
||||
},
|
||||
// wrong confirmation password
|
||||
{
|
||||
`{
|
||||
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwibmV3RW1haWwiOiJ0ZXN0X25ld0BleGFtcGxlLmNvbSIsImV4cCI6MjIwODk4NTI2MX0.hmR7Ye23C68tS1LgHgYgT7NBJczTad34kzcT4sqW3FY",
|
||||
"password": "123456"
|
||||
}`,
|
||||
[]string{"password"},
|
||||
},
|
||||
// valid data
|
||||
{
|
||||
`{
|
||||
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwibmV3RW1haWwiOiJ0ZXN0X25ld0BleGFtcGxlLmNvbSIsImV4cCI6MjIwODk4NTI2MX0.hmR7Ye23C68tS1LgHgYgT7NBJczTad34kzcT4sqW3FY",
|
||||
"password": "1234567890"
|
||||
}`,
|
||||
[]string{},
|
||||
},
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
form := forms.NewRecordEmailChangeConfirm(testApp, authCollection)
|
||||
|
||||
// load data
|
||||
loadErr := json.Unmarshal([]byte(s.jsonData), form)
|
||||
if loadErr != nil {
|
||||
t.Errorf("(%d) Failed to load form data: %v", i, loadErr)
|
||||
continue
|
||||
}
|
||||
|
||||
interceptorCalls := 0
|
||||
interceptor := func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
|
||||
return func(r *models.Record) error {
|
||||
interceptorCalls++
|
||||
return next(r)
|
||||
}
|
||||
}
|
||||
|
||||
record, err := form.Submit(interceptor)
|
||||
|
||||
// check interceptor calls
|
||||
expectInterceptorCalls := 1
|
||||
if len(s.expectedErrors) > 0 {
|
||||
expectInterceptorCalls = 0
|
||||
}
|
||||
if interceptorCalls != expectInterceptorCalls {
|
||||
t.Errorf("[%d] Expected interceptor to be called %d, got %d", i, expectInterceptorCalls, interceptorCalls)
|
||||
}
|
||||
|
||||
// parse errors
|
||||
errs, ok := err.(validation.Errors)
|
||||
if !ok && err != nil {
|
||||
t.Errorf("(%d) Failed to parse errors %v", i, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// check errors
|
||||
if len(errs) > len(s.expectedErrors) {
|
||||
t.Errorf("(%d) Expected error keys %v, got %v", i, s.expectedErrors, errs)
|
||||
}
|
||||
for _, k := range s.expectedErrors {
|
||||
if _, ok := errs[k]; !ok {
|
||||
t.Errorf("(%d) Missing expected error key %q in %v", i, k, errs)
|
||||
}
|
||||
}
|
||||
|
||||
if len(errs) > 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
claims, _ := security.ParseUnverifiedJWT(form.Token)
|
||||
newEmail, _ := claims["newEmail"].(string)
|
||||
|
||||
// check whether the user was updated
|
||||
// ---
|
||||
if record.Email() != newEmail {
|
||||
t.Errorf("(%d) Expected record email %q, got %q", i, newEmail, record.Email())
|
||||
}
|
||||
|
||||
if !record.Verified() {
|
||||
t.Errorf("(%d) Expected record to be verified, got false", i)
|
||||
}
|
||||
|
||||
// shouldn't validate second time due to refreshed record token
|
||||
if err := form.Validate(); err == nil {
|
||||
t.Errorf("(%d) Expected error, got nil", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordEmailChangeConfirmInterceptors(t *testing.T) {
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
|
||||
authCollection, err := testApp.Dao().FindCollectionByNameOrId("users")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
authRecord, err := testApp.Dao().FindAuthRecordByEmail("users", "test@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
form := forms.NewRecordEmailChangeConfirm(testApp, authCollection)
|
||||
form.Token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwibmV3RW1haWwiOiJ0ZXN0X25ld0BleGFtcGxlLmNvbSIsImV4cCI6MjIwODk4NTI2MX0.hmR7Ye23C68tS1LgHgYgT7NBJczTad34kzcT4sqW3FY"
|
||||
form.Password = "1234567890"
|
||||
interceptorEmail := authRecord.Email()
|
||||
testErr := errors.New("test_error")
|
||||
|
||||
interceptor1Called := false
|
||||
interceptor1 := func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
|
||||
return func(record *models.Record) error {
|
||||
interceptor1Called = true
|
||||
return next(record)
|
||||
}
|
||||
}
|
||||
|
||||
interceptor2Called := false
|
||||
interceptor2 := func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
|
||||
return func(record *models.Record) error {
|
||||
interceptorEmail = record.Email()
|
||||
interceptor2Called = true
|
||||
return testErr
|
||||
}
|
||||
}
|
||||
|
||||
_, submitErr := form.Submit(interceptor1, interceptor2)
|
||||
if submitErr != testErr {
|
||||
t.Fatalf("Expected submitError %v, got %v", testErr, submitErr)
|
||||
}
|
||||
|
||||
if !interceptor1Called {
|
||||
t.Fatalf("Expected interceptor1 to be called")
|
||||
}
|
||||
|
||||
if !interceptor2Called {
|
||||
t.Fatalf("Expected interceptor2 to be called")
|
||||
}
|
||||
|
||||
if interceptorEmail == authRecord.Email() {
|
||||
t.Fatalf("Expected the form model to be filled before calling the interceptors")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package forms
|
||||
|
||||
import (
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/go-ozzo/ozzo-validation/v4/is"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
"github.com/pocketbase/pocketbase/mails"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/models/schema"
|
||||
)
|
||||
|
||||
// RecordEmailChangeRequest is an auth record email change request form.
|
||||
type RecordEmailChangeRequest struct {
|
||||
app core.App
|
||||
dao *daos.Dao
|
||||
record *models.Record
|
||||
|
||||
NewEmail string `form:"newEmail" json:"newEmail"`
|
||||
}
|
||||
|
||||
// NewRecordEmailChangeRequest creates a new [RecordEmailChangeRequest] form
|
||||
// initialized with from the provided [core.App] and [models.Record] instances.
|
||||
//
|
||||
// If you want to submit the form as part of a transaction,
|
||||
// you can change the default Dao via [SetDao()].
|
||||
func NewRecordEmailChangeRequest(app core.App, record *models.Record) *RecordEmailChangeRequest {
|
||||
return &RecordEmailChangeRequest{
|
||||
app: app,
|
||||
dao: app.Dao(),
|
||||
record: record,
|
||||
}
|
||||
}
|
||||
|
||||
// SetDao replaces the default form Dao instance with the provided one.
|
||||
func (form *RecordEmailChangeRequest) SetDao(dao *daos.Dao) {
|
||||
form.dao = dao
|
||||
}
|
||||
|
||||
// Validate makes the form validatable by implementing [validation.Validatable] interface.
|
||||
func (form *RecordEmailChangeRequest) Validate() error {
|
||||
return validation.ValidateStruct(form,
|
||||
validation.Field(
|
||||
&form.NewEmail,
|
||||
validation.Required,
|
||||
validation.Length(1, 255),
|
||||
is.EmailFormat,
|
||||
validation.By(form.checkUniqueEmail),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func (form *RecordEmailChangeRequest) checkUniqueEmail(value any) error {
|
||||
v, _ := value.(string)
|
||||
|
||||
if !form.dao.IsRecordValueUnique(form.record.Collection().Id, schema.FieldNameEmail, v) {
|
||||
return validation.NewError("validation_record_email_exists", "User email already exists.")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Submit validates and sends the change email request.
|
||||
//
|
||||
// You can optionally provide a list of InterceptorFunc to
|
||||
// further modify the form behavior before persisting it.
|
||||
func (form *RecordEmailChangeRequest) Submit(interceptors ...InterceptorFunc[*models.Record]) error {
|
||||
if err := form.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return runInterceptors(form.record, func(m *models.Record) error {
|
||||
return mails.SendRecordChangeEmail(form.app, m, form.NewEmail)
|
||||
}, interceptors...)
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package forms_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/pocketbase/pocketbase/forms"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
)
|
||||
|
||||
func TestRecordEmailChangeRequestValidateAndSubmit(t *testing.T) {
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
|
||||
user, err := testApp.Dao().FindAuthRecordByEmail("users", "test@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
scenarios := []struct {
|
||||
jsonData string
|
||||
expectedErrors []string
|
||||
}{
|
||||
// empty payload
|
||||
{"{}", []string{"newEmail"}},
|
||||
// empty data
|
||||
{
|
||||
`{"newEmail": ""}`,
|
||||
[]string{"newEmail"},
|
||||
},
|
||||
// invalid email
|
||||
{
|
||||
`{"newEmail": "invalid"}`,
|
||||
[]string{"newEmail"},
|
||||
},
|
||||
// existing email token
|
||||
{
|
||||
`{"newEmail": "test2@example.com"}`,
|
||||
[]string{"newEmail"},
|
||||
},
|
||||
// valid new email
|
||||
{
|
||||
`{"newEmail": "test_new@example.com"}`,
|
||||
[]string{},
|
||||
},
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
testApp.TestMailer.TotalSend = 0 // reset
|
||||
form := forms.NewRecordEmailChangeRequest(testApp, user)
|
||||
|
||||
// load data
|
||||
loadErr := json.Unmarshal([]byte(s.jsonData), form)
|
||||
if loadErr != nil {
|
||||
t.Errorf("(%d) Failed to load form data: %v", i, loadErr)
|
||||
continue
|
||||
}
|
||||
|
||||
interceptorCalls := 0
|
||||
interceptor := func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
|
||||
return func(r *models.Record) error {
|
||||
interceptorCalls++
|
||||
return next(r)
|
||||
}
|
||||
}
|
||||
|
||||
err := form.Submit(interceptor)
|
||||
|
||||
// check interceptor calls
|
||||
expectInterceptorCalls := 1
|
||||
if len(s.expectedErrors) > 0 {
|
||||
expectInterceptorCalls = 0
|
||||
}
|
||||
if interceptorCalls != expectInterceptorCalls {
|
||||
t.Errorf("[%d] Expected interceptor to be called %d, got %d", i, expectInterceptorCalls, interceptorCalls)
|
||||
}
|
||||
|
||||
// parse errors
|
||||
errs, ok := err.(validation.Errors)
|
||||
if !ok && err != nil {
|
||||
t.Errorf("(%d) Failed to parse errors %v", i, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// check errors
|
||||
if len(errs) > len(s.expectedErrors) {
|
||||
t.Errorf("(%d) Expected error keys %v, got %v", i, s.expectedErrors, errs)
|
||||
}
|
||||
for _, k := range s.expectedErrors {
|
||||
if _, ok := errs[k]; !ok {
|
||||
t.Errorf("(%d) Missing expected error key %q in %v", i, k, errs)
|
||||
}
|
||||
}
|
||||
|
||||
expectedMails := 1
|
||||
if len(s.expectedErrors) > 0 {
|
||||
expectedMails = 0
|
||||
}
|
||||
if testApp.TestMailer.TotalSend != expectedMails {
|
||||
t.Errorf("(%d) Expected %d mail(s) to be sent, got %d", i, expectedMails, testApp.TestMailer.TotalSend)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordEmailChangeRequestInterceptors(t *testing.T) {
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
|
||||
authRecord, err := testApp.Dao().FindAuthRecordByEmail("users", "test@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
form := forms.NewRecordEmailChangeRequest(testApp, authRecord)
|
||||
form.NewEmail = "test_new@example.com"
|
||||
testErr := errors.New("test_error")
|
||||
|
||||
interceptor1Called := false
|
||||
interceptor1 := func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
|
||||
return func(record *models.Record) error {
|
||||
interceptor1Called = true
|
||||
return next(record)
|
||||
}
|
||||
}
|
||||
|
||||
interceptor2Called := false
|
||||
interceptor2 := func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
|
||||
return func(record *models.Record) error {
|
||||
interceptor2Called = true
|
||||
return testErr
|
||||
}
|
||||
}
|
||||
|
||||
submitErr := form.Submit(interceptor1, interceptor2)
|
||||
if submitErr != testErr {
|
||||
t.Fatalf("Expected submitError %v, got %v", testErr, submitErr)
|
||||
}
|
||||
|
||||
if !interceptor1Called {
|
||||
t.Fatalf("Expected interceptor1 to be called")
|
||||
}
|
||||
|
||||
if !interceptor2Called {
|
||||
t.Fatalf("Expected interceptor2 to be called")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
package forms
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/go-ozzo/ozzo-validation/v4/is"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/tools/auth"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
// RecordOAuth2LoginData defines the OA
|
||||
type RecordOAuth2LoginData struct {
|
||||
ExternalAuth *models.ExternalAuth
|
||||
Record *models.Record
|
||||
OAuth2User *auth.AuthUser
|
||||
}
|
||||
|
||||
// BeforeOAuth2RecordCreateFunc defines a callback function that will
|
||||
// be called before OAuth2 new Record creation.
|
||||
type BeforeOAuth2RecordCreateFunc func(createForm *RecordUpsert, authRecord *models.Record, authUser *auth.AuthUser) error
|
||||
|
||||
// RecordOAuth2Login is an auth record OAuth2 login form.
|
||||
type RecordOAuth2Login struct {
|
||||
app core.App
|
||||
dao *daos.Dao
|
||||
collection *models.Collection
|
||||
|
||||
beforeOAuth2RecordCreateFunc BeforeOAuth2RecordCreateFunc
|
||||
|
||||
// Optional auth record that will be used if no external
|
||||
// auth relation is found (if it is from the same collection)
|
||||
loggedAuthRecord *models.Record
|
||||
|
||||
// The name of the OAuth2 client provider (eg. "google")
|
||||
Provider string `form:"provider" json:"provider"`
|
||||
|
||||
// The authorization code returned from the initial request.
|
||||
Code string `form:"code" json:"code"`
|
||||
|
||||
// The code verifier sent with the initial request as part of the code_challenge.
|
||||
CodeVerifier string `form:"codeVerifier" json:"codeVerifier"`
|
||||
|
||||
// The redirect url sent with the initial request.
|
||||
RedirectUrl string `form:"redirectUrl" json:"redirectUrl"`
|
||||
|
||||
// Additional data that will be used for creating a new auth record
|
||||
// if an existing OAuth2 account doesn't exist.
|
||||
CreateData map[string]any `form:"createData" json:"createData"`
|
||||
}
|
||||
|
||||
// NewRecordOAuth2Login creates a new [RecordOAuth2Login] form with
|
||||
// initialized with from the provided [core.App] instance.
|
||||
//
|
||||
// If you want to submit the form as part of a transaction,
|
||||
// you can change the default Dao via [SetDao()].
|
||||
func NewRecordOAuth2Login(app core.App, collection *models.Collection, optAuthRecord *models.Record) *RecordOAuth2Login {
|
||||
form := &RecordOAuth2Login{
|
||||
app: app,
|
||||
dao: app.Dao(),
|
||||
collection: collection,
|
||||
loggedAuthRecord: optAuthRecord,
|
||||
}
|
||||
|
||||
return form
|
||||
}
|
||||
|
||||
// SetDao replaces the default form Dao instance with the provided one.
|
||||
func (form *RecordOAuth2Login) SetDao(dao *daos.Dao) {
|
||||
form.dao = dao
|
||||
}
|
||||
|
||||
// SetBeforeNewRecordCreateFunc sets a before OAuth2 record create callback handler.
|
||||
func (form *RecordOAuth2Login) SetBeforeNewRecordCreateFunc(f BeforeOAuth2RecordCreateFunc) {
|
||||
form.beforeOAuth2RecordCreateFunc = f
|
||||
}
|
||||
|
||||
// Validate makes the form validatable by implementing [validation.Validatable] interface.
|
||||
func (form *RecordOAuth2Login) Validate() error {
|
||||
return validation.ValidateStruct(form,
|
||||
validation.Field(&form.Provider, validation.Required, validation.By(form.checkProviderName)),
|
||||
validation.Field(&form.Code, validation.Required),
|
||||
validation.Field(&form.CodeVerifier, validation.Required),
|
||||
validation.Field(&form.RedirectUrl, validation.Required, is.URL),
|
||||
)
|
||||
}
|
||||
|
||||
func (form *RecordOAuth2Login) checkProviderName(value any) error {
|
||||
name, _ := value.(string)
|
||||
|
||||
config, ok := form.app.Settings().NamedAuthProviderConfigs()[name]
|
||||
if !ok || !config.Enabled {
|
||||
return validation.NewError("validation_invalid_provider", fmt.Sprintf("%q is missing or is not enabled.", name))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Submit validates and submits the form.
|
||||
//
|
||||
// If an auth record doesn't exist, it will make an attempt to create it
|
||||
// based on the fetched OAuth2 profile data via a local [RecordUpsert] form.
|
||||
// You can intercept/modify the Record create form with [form.SetBeforeNewRecordCreateFunc()].
|
||||
//
|
||||
// You can also optionally provide a list of InterceptorFunc to
|
||||
// further modify the form behavior before persisting it.
|
||||
//
|
||||
// On success returns the authorized record model and the fetched provider's data.
|
||||
func (form *RecordOAuth2Login) Submit(
|
||||
interceptors ...InterceptorFunc[*RecordOAuth2LoginData],
|
||||
) (*models.Record, *auth.AuthUser, error) {
|
||||
if err := form.Validate(); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
if !form.collection.AuthOptions().AllowOAuth2Auth {
|
||||
return nil, nil, errors.New("OAuth2 authentication is not allowed for the auth collection.")
|
||||
}
|
||||
|
||||
provider, err := auth.NewProviderByName(form.Provider)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// load provider configuration
|
||||
providerConfig := form.app.Settings().NamedAuthProviderConfigs()[form.Provider]
|
||||
if err := providerConfig.SetupProvider(provider); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
provider.SetRedirectUrl(form.RedirectUrl)
|
||||
|
||||
// fetch token
|
||||
token, err := provider.FetchToken(
|
||||
form.Code,
|
||||
oauth2.SetAuthURLParam("code_verifier", form.CodeVerifier),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// fetch external auth user
|
||||
authUser, err := provider.FetchAuthUser(token)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
var authRecord *models.Record
|
||||
|
||||
// check for existing relation with the auth record
|
||||
rel, _ := form.dao.FindExternalAuthByProvider(form.Provider, authUser.Id)
|
||||
switch {
|
||||
case rel != nil:
|
||||
authRecord, err = form.dao.FindRecordById(form.collection.Id, rel.RecordId)
|
||||
if err != nil {
|
||||
return nil, authUser, err
|
||||
}
|
||||
case form.loggedAuthRecord != nil && form.loggedAuthRecord.Collection().Id == form.collection.Id:
|
||||
// fallback to the logged auth record (if any)
|
||||
authRecord = form.loggedAuthRecord
|
||||
case authUser.Email != "":
|
||||
// look for an existing auth record by the external auth record's email
|
||||
authRecord, _ = form.dao.FindAuthRecordByEmail(form.collection.Id, authUser.Email)
|
||||
}
|
||||
|
||||
interceptorData := &RecordOAuth2LoginData{
|
||||
ExternalAuth: rel,
|
||||
Record: authRecord,
|
||||
OAuth2User: authUser,
|
||||
}
|
||||
|
||||
interceptorsErr := runInterceptors(interceptorData, func(newData *RecordOAuth2LoginData) error {
|
||||
return form.submit(newData)
|
||||
}, interceptors...)
|
||||
|
||||
if interceptorsErr != nil {
|
||||
return nil, interceptorData.OAuth2User, interceptorsErr
|
||||
}
|
||||
|
||||
return interceptorData.Record, interceptorData.OAuth2User, nil
|
||||
}
|
||||
|
||||
func (form *RecordOAuth2Login) submit(data *RecordOAuth2LoginData) error {
|
||||
return form.dao.RunInTransaction(func(txDao *daos.Dao) error {
|
||||
if data.Record == nil {
|
||||
data.Record = models.NewRecord(form.collection)
|
||||
data.Record.RefreshId()
|
||||
data.Record.MarkAsNew()
|
||||
createForm := NewRecordUpsert(form.app, data.Record)
|
||||
createForm.SetFullManageAccess(true)
|
||||
createForm.SetDao(txDao)
|
||||
if data.OAuth2User.Username != "" &&
|
||||
len(data.OAuth2User.Username) >= 3 &&
|
||||
len(data.OAuth2User.Username) <= 150 &&
|
||||
usernameRegex.MatchString(data.OAuth2User.Username) {
|
||||
createForm.Username = form.dao.SuggestUniqueAuthRecordUsername(
|
||||
form.collection.Id,
|
||||
data.OAuth2User.Username,
|
||||
)
|
||||
}
|
||||
|
||||
// load custom data
|
||||
createForm.LoadData(form.CreateData)
|
||||
|
||||
// load the OAuth2 profile data as fallback
|
||||
if createForm.Email == "" {
|
||||
createForm.Email = data.OAuth2User.Email
|
||||
}
|
||||
createForm.Verified = false
|
||||
if createForm.Email == data.OAuth2User.Email {
|
||||
// mark as verified as long as it matches the OAuth2 data (even if the email is empty)
|
||||
createForm.Verified = true
|
||||
}
|
||||
if createForm.Password == "" {
|
||||
createForm.Password = security.RandomString(30)
|
||||
createForm.PasswordConfirm = createForm.Password
|
||||
}
|
||||
|
||||
if form.beforeOAuth2RecordCreateFunc != nil {
|
||||
if err := form.beforeOAuth2RecordCreateFunc(createForm, data.Record, data.OAuth2User); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// create the new auth record
|
||||
if err := createForm.Submit(); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
// update the existing auth record empty email if the data.OAuth2User has one
|
||||
// (this is in case previously the auth record was created
|
||||
// with an OAuth2 provider that didn't return an email address)
|
||||
if data.Record.Email() == "" && data.OAuth2User.Email != "" {
|
||||
data.Record.SetEmail(data.OAuth2User.Email)
|
||||
if err := txDao.SaveRecord(data.Record); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// update the existing auth record verified state
|
||||
// (only if the auth record doesn't have an email or the auth record email match with the one in data.OAuth2User)
|
||||
if !data.Record.Verified() && (data.Record.Email() == "" || data.Record.Email() == data.OAuth2User.Email) {
|
||||
data.Record.SetVerified(true)
|
||||
if err := txDao.SaveRecord(data.Record); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// create ExternalAuth relation if missing
|
||||
if data.ExternalAuth == nil {
|
||||
data.ExternalAuth = &models.ExternalAuth{
|
||||
CollectionId: data.Record.Collection().Id,
|
||||
RecordId: data.Record.Id,
|
||||
Provider: form.Provider,
|
||||
ProviderId: data.OAuth2User.Id,
|
||||
}
|
||||
if err := txDao.SaveExternalAuth(data.ExternalAuth); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -9,55 +9,60 @@ import (
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
)
|
||||
|
||||
func TestUserOauth2LoginPanic(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("The form did not panic")
|
||||
}
|
||||
}()
|
||||
|
||||
forms.NewUserOauth2Login(nil)
|
||||
}
|
||||
|
||||
func TestUserOauth2LoginValidate(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
scenarios := []struct {
|
||||
testName string
|
||||
collectionName string
|
||||
jsonData string
|
||||
expectedErrors []string
|
||||
}{
|
||||
// empty payload
|
||||
{"{}", []string{"provider", "code", "codeVerifier", "redirectUrl"}},
|
||||
// empty data
|
||||
{
|
||||
"empty payload",
|
||||
"users",
|
||||
"{}",
|
||||
[]string{"provider", "code", "codeVerifier", "redirectUrl"},
|
||||
},
|
||||
{
|
||||
"empty data",
|
||||
"users",
|
||||
`{"provider":"","code":"","codeVerifier":"","redirectUrl":""}`,
|
||||
[]string{"provider", "code", "codeVerifier", "redirectUrl"},
|
||||
},
|
||||
// missing provider
|
||||
{
|
||||
"missing provider",
|
||||
"users",
|
||||
`{"provider":"missing","code":"123","codeVerifier":"123","redirectUrl":"https://example.com"}`,
|
||||
[]string{"provider"},
|
||||
},
|
||||
// disabled provider
|
||||
{
|
||||
"disabled provider",
|
||||
"users",
|
||||
`{"provider":"github","code":"123","codeVerifier":"123","redirectUrl":"https://example.com"}`,
|
||||
[]string{"provider"},
|
||||
},
|
||||
// enabled provider
|
||||
{
|
||||
"enabled provider",
|
||||
"users",
|
||||
`{"provider":"gitlab","code":"123","codeVerifier":"123","redirectUrl":"https://example.com"}`,
|
||||
[]string{},
|
||||
},
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
form := forms.NewUserOauth2Login(app)
|
||||
for _, s := range scenarios {
|
||||
authCollection, _ := app.Dao().FindCollectionByNameOrId(s.collectionName)
|
||||
if authCollection == nil {
|
||||
t.Errorf("[%s] Failed to fetch auth collection", s.testName)
|
||||
}
|
||||
|
||||
form := forms.NewRecordOAuth2Login(app, authCollection, nil)
|
||||
|
||||
// load data
|
||||
loadErr := json.Unmarshal([]byte(s.jsonData), form)
|
||||
if loadErr != nil {
|
||||
t.Errorf("(%d) Failed to load form data: %v", i, loadErr)
|
||||
t.Errorf("[%s] Failed to load form data: %v", s.testName, loadErr)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -66,17 +71,17 @@ func TestUserOauth2LoginValidate(t *testing.T) {
|
||||
// parse errors
|
||||
errs, ok := err.(validation.Errors)
|
||||
if !ok && err != nil {
|
||||
t.Errorf("(%d) Failed to parse errors %v", i, err)
|
||||
t.Errorf("[%s] Failed to parse errors %v", s.testName, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// check errors
|
||||
if len(errs) > len(s.expectedErrors) {
|
||||
t.Errorf("(%d) Expected error keys %v, got %v", i, s.expectedErrors, errs)
|
||||
t.Errorf("[%s] Expected error keys %v, got %v", s.testName, s.expectedErrors, errs)
|
||||
}
|
||||
for _, k := range s.expectedErrors {
|
||||
if _, ok := errs[k]; !ok {
|
||||
t.Errorf("(%d) Missing expected error key %q in %v", i, k, errs)
|
||||
t.Errorf("[%s] Missing expected error key %q in %v", s.testName, k, errs)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package forms
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/go-ozzo/ozzo-validation/v4/is"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
)
|
||||
|
||||
// RecordPasswordLogin is record username/email + password login form.
|
||||
type RecordPasswordLogin struct {
|
||||
app core.App
|
||||
dao *daos.Dao
|
||||
collection *models.Collection
|
||||
|
||||
Identity string `form:"identity" json:"identity"`
|
||||
Password string `form:"password" json:"password"`
|
||||
}
|
||||
|
||||
// NewRecordPasswordLogin creates a new [RecordPasswordLogin] form initialized
|
||||
// with from the provided [core.App] and [models.Collection] instance.
|
||||
//
|
||||
// If you want to submit the form as part of a transaction,
|
||||
// you can change the default Dao via [SetDao()].
|
||||
func NewRecordPasswordLogin(app core.App, collection *models.Collection) *RecordPasswordLogin {
|
||||
return &RecordPasswordLogin{
|
||||
app: app,
|
||||
dao: app.Dao(),
|
||||
collection: collection,
|
||||
}
|
||||
}
|
||||
|
||||
// SetDao replaces the default form Dao instance with the provided one.
|
||||
func (form *RecordPasswordLogin) SetDao(dao *daos.Dao) {
|
||||
form.dao = dao
|
||||
}
|
||||
|
||||
// Validate makes the form validatable by implementing [validation.Validatable] interface.
|
||||
func (form *RecordPasswordLogin) Validate() error {
|
||||
return validation.ValidateStruct(form,
|
||||
validation.Field(&form.Identity, validation.Required, validation.Length(1, 255)),
|
||||
validation.Field(&form.Password, validation.Required, validation.Length(1, 255)),
|
||||
)
|
||||
}
|
||||
|
||||
// Submit validates and submits the form.
|
||||
// On success returns the authorized record model.
|
||||
//
|
||||
// You can optionally provide a list of InterceptorFunc to
|
||||
// further modify the form behavior before persisting it.
|
||||
func (form *RecordPasswordLogin) Submit(interceptors ...InterceptorFunc[*models.Record]) (*models.Record, error) {
|
||||
if err := form.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
authOptions := form.collection.AuthOptions()
|
||||
|
||||
var authRecord *models.Record
|
||||
var fetchErr error
|
||||
|
||||
isEmail := is.EmailFormat.Validate(form.Identity) == nil
|
||||
|
||||
if isEmail {
|
||||
if authOptions.AllowEmailAuth {
|
||||
authRecord, fetchErr = form.dao.FindAuthRecordByEmail(form.collection.Id, form.Identity)
|
||||
}
|
||||
} else if authOptions.AllowUsernameAuth {
|
||||
authRecord, fetchErr = form.dao.FindAuthRecordByUsername(form.collection.Id, form.Identity)
|
||||
}
|
||||
|
||||
// ignore not found errors to allow custom fetch implementations
|
||||
if fetchErr != nil && !errors.Is(fetchErr, sql.ErrNoRows) {
|
||||
return nil, fetchErr
|
||||
}
|
||||
|
||||
interceptorsErr := runInterceptors(authRecord, func(m *models.Record) error {
|
||||
authRecord = m
|
||||
|
||||
if authRecord == nil || !authRecord.ValidatePassword(form.Password) {
|
||||
return errors.New("Invalid login credentials.")
|
||||
}
|
||||
|
||||
return nil
|
||||
}, interceptors...)
|
||||
|
||||
if interceptorsErr != nil {
|
||||
return nil, interceptorsErr
|
||||
}
|
||||
|
||||
return authRecord, nil
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
package forms_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/pocketbase/pocketbase/forms"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
)
|
||||
|
||||
func TestRecordPasswordLoginValidateAndSubmit(t *testing.T) {
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
|
||||
scenarios := []struct {
|
||||
testName string
|
||||
collectionName string
|
||||
identity string
|
||||
password string
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
"empty data",
|
||||
"users",
|
||||
"",
|
||||
"",
|
||||
true,
|
||||
},
|
||||
|
||||
// username
|
||||
{
|
||||
"existing username + wrong password",
|
||||
"users",
|
||||
"users75657",
|
||||
"invalid",
|
||||
true,
|
||||
},
|
||||
{
|
||||
"missing username + valid password",
|
||||
"users",
|
||||
"clients57772", // not in the "users" collection
|
||||
"1234567890",
|
||||
true,
|
||||
},
|
||||
{
|
||||
"existing username + valid password but in restricted username auth collection",
|
||||
"clients",
|
||||
"clients57772",
|
||||
"1234567890",
|
||||
true,
|
||||
},
|
||||
{
|
||||
"existing username + valid password but in restricted username and email auth collection",
|
||||
"nologin",
|
||||
"test_username",
|
||||
"1234567890",
|
||||
true,
|
||||
},
|
||||
{
|
||||
"existing username + valid password",
|
||||
"users",
|
||||
"users75657",
|
||||
"1234567890",
|
||||
false,
|
||||
},
|
||||
|
||||
// email
|
||||
{
|
||||
"existing email + wrong password",
|
||||
"users",
|
||||
"test@example.com",
|
||||
"invalid",
|
||||
true,
|
||||
},
|
||||
{
|
||||
"missing email + valid password",
|
||||
"users",
|
||||
"test_missing@example.com",
|
||||
"1234567890",
|
||||
true,
|
||||
},
|
||||
{
|
||||
"existing username + valid password but in restricted username auth collection",
|
||||
"clients",
|
||||
"test@example.com",
|
||||
"1234567890",
|
||||
false,
|
||||
},
|
||||
{
|
||||
"existing username + valid password but in restricted username and email auth collection",
|
||||
"nologin",
|
||||
"test@example.com",
|
||||
"1234567890",
|
||||
true,
|
||||
},
|
||||
{
|
||||
"existing email + valid password",
|
||||
"users",
|
||||
"test@example.com",
|
||||
"1234567890",
|
||||
false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
authCollection, err := testApp.Dao().FindCollectionByNameOrId(s.collectionName)
|
||||
if err != nil {
|
||||
t.Errorf("[%s] Failed to fetch auth collection: %v", s.testName, err)
|
||||
}
|
||||
|
||||
form := forms.NewRecordPasswordLogin(testApp, authCollection)
|
||||
form.Identity = s.identity
|
||||
form.Password = s.password
|
||||
|
||||
record, err := form.Submit()
|
||||
|
||||
hasErr := err != nil
|
||||
if hasErr != s.expectError {
|
||||
t.Errorf("[%s] Expected hasErr to be %v, got %v (%v)", s.testName, s.expectError, hasErr, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if hasErr {
|
||||
continue
|
||||
}
|
||||
|
||||
if record.Email() != s.identity && record.Username() != s.identity {
|
||||
t.Errorf("[%s] Expected record with identity %q, got \n%v", s.testName, s.identity, record)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordPasswordLoginInterceptors(t *testing.T) {
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
|
||||
authCollection, err := testApp.Dao().FindCollectionByNameOrId("users")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
form := forms.NewRecordPasswordLogin(testApp, authCollection)
|
||||
form.Identity = "test@example.com"
|
||||
form.Password = "123456"
|
||||
var interceptorRecord *models.Record
|
||||
testErr := errors.New("test_error")
|
||||
|
||||
interceptor1Called := false
|
||||
interceptor1 := func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
|
||||
return func(record *models.Record) error {
|
||||
interceptor1Called = true
|
||||
return next(record)
|
||||
}
|
||||
}
|
||||
|
||||
interceptor2Called := false
|
||||
interceptor2 := func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
|
||||
return func(record *models.Record) error {
|
||||
interceptorRecord = record
|
||||
interceptor2Called = true
|
||||
return testErr
|
||||
}
|
||||
}
|
||||
|
||||
_, submitErr := form.Submit(interceptor1, interceptor2)
|
||||
if submitErr != testErr {
|
||||
t.Fatalf("Expected submitError %v, got %v", testErr, submitErr)
|
||||
}
|
||||
|
||||
if !interceptor1Called {
|
||||
t.Fatalf("Expected interceptor1 to be called")
|
||||
}
|
||||
|
||||
if !interceptor2Called {
|
||||
t.Fatalf("Expected interceptor2 to be called")
|
||||
}
|
||||
|
||||
if interceptorRecord == nil || interceptorRecord.Email() != form.Identity {
|
||||
t.Fatalf("Expected auth Record model with email %s, got %v", form.Identity, interceptorRecord)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package forms
|
||||
|
||||
import (
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
"github.com/pocketbase/pocketbase/forms/validators"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
)
|
||||
|
||||
// RecordPasswordResetConfirm is an auth record password reset confirmation form.
|
||||
type RecordPasswordResetConfirm struct {
|
||||
app core.App
|
||||
collection *models.Collection
|
||||
dao *daos.Dao
|
||||
|
||||
Token string `form:"token" json:"token"`
|
||||
Password string `form:"password" json:"password"`
|
||||
PasswordConfirm string `form:"passwordConfirm" json:"passwordConfirm"`
|
||||
}
|
||||
|
||||
// NewRecordPasswordResetConfirm creates a new [RecordPasswordResetConfirm]
|
||||
// form initialized with from the provided [core.App] instance.
|
||||
//
|
||||
// If you want to submit the form as part of a transaction,
|
||||
// you can change the default Dao via [SetDao()].
|
||||
func NewRecordPasswordResetConfirm(app core.App, collection *models.Collection) *RecordPasswordResetConfirm {
|
||||
return &RecordPasswordResetConfirm{
|
||||
app: app,
|
||||
dao: app.Dao(),
|
||||
collection: collection,
|
||||
}
|
||||
}
|
||||
|
||||
// SetDao replaces the default form Dao instance with the provided one.
|
||||
func (form *RecordPasswordResetConfirm) SetDao(dao *daos.Dao) {
|
||||
form.dao = dao
|
||||
}
|
||||
|
||||
// Validate makes the form validatable by implementing [validation.Validatable] interface.
|
||||
func (form *RecordPasswordResetConfirm) Validate() error {
|
||||
minPasswordLength := form.collection.AuthOptions().MinPasswordLength
|
||||
|
||||
return validation.ValidateStruct(form,
|
||||
validation.Field(&form.Token, validation.Required, validation.By(form.checkToken)),
|
||||
validation.Field(&form.Password, validation.Required, validation.Length(minPasswordLength, 100)),
|
||||
validation.Field(&form.PasswordConfirm, validation.Required, validation.By(validators.Compare(form.Password))),
|
||||
)
|
||||
}
|
||||
|
||||
func (form *RecordPasswordResetConfirm) checkToken(value any) error {
|
||||
v, _ := value.(string)
|
||||
if v == "" {
|
||||
return nil // nothing to check
|
||||
}
|
||||
|
||||
record, err := form.dao.FindAuthRecordByToken(
|
||||
v,
|
||||
form.app.Settings().RecordPasswordResetToken.Secret,
|
||||
)
|
||||
if err != nil || record == nil {
|
||||
return validation.NewError("validation_invalid_token", "Invalid or expired token.")
|
||||
}
|
||||
|
||||
if record.Collection().Id != form.collection.Id {
|
||||
return validation.NewError("validation_token_collection_mismatch", "The provided token is for different auth collection.")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Submit validates and submits the form.
|
||||
// On success returns the updated auth record associated to `form.Token`.
|
||||
//
|
||||
// You can optionally provide a list of InterceptorFunc to further
|
||||
// modify the form behavior before persisting it.
|
||||
func (form *RecordPasswordResetConfirm) Submit(interceptors ...InterceptorFunc[*models.Record]) (*models.Record, error) {
|
||||
if err := form.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
authRecord, err := form.dao.FindAuthRecordByToken(
|
||||
form.Token,
|
||||
form.app.Settings().RecordPasswordResetToken.Secret,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := authRecord.SetPassword(form.Password); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
interceptorsErr := runInterceptors(authRecord, func(m *models.Record) error {
|
||||
authRecord = m
|
||||
return form.dao.SaveRecord(m)
|
||||
}, interceptors...)
|
||||
|
||||
if interceptorsErr != nil {
|
||||
return nil, interceptorsErr
|
||||
}
|
||||
|
||||
return authRecord, nil
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
package forms_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/pocketbase/pocketbase/forms"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
)
|
||||
|
||||
func TestRecordPasswordResetConfirmValidateAndSubmit(t *testing.T) {
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
|
||||
authCollection, err := testApp.Dao().FindCollectionByNameOrId("users")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
scenarios := []struct {
|
||||
jsonData string
|
||||
expectedErrors []string
|
||||
}{
|
||||
// empty data (Validate call check)
|
||||
{
|
||||
`{}`,
|
||||
[]string{"token", "password", "passwordConfirm"},
|
||||
},
|
||||
// expired token
|
||||
{
|
||||
`{
|
||||
"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZXhwIjoxNjQwOTkxNjYxfQ.TayHoXkOTM0w8InkBEb86npMJEaf6YVUrxrRmMgFjeY",
|
||||
"password":"12345678",
|
||||
"passwordConfirm":"12345678"
|
||||
}`,
|
||||
[]string{"token"},
|
||||
},
|
||||
// valid token but invalid passwords lengths
|
||||
{
|
||||
`{
|
||||
"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZXhwIjoyMjA4OTg1MjYxfQ.R_4FOSUHIuJQ5Crl3PpIPCXMsoHzuTaNlccpXg_3FOg",
|
||||
"password":"1234567",
|
||||
"passwordConfirm":"1234567"
|
||||
}`,
|
||||
[]string{"password"},
|
||||
},
|
||||
// valid token but mismatched passwordConfirm
|
||||
{
|
||||
`{
|
||||
"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZXhwIjoyMjA4OTg1MjYxfQ.R_4FOSUHIuJQ5Crl3PpIPCXMsoHzuTaNlccpXg_3FOg",
|
||||
"password":"12345678",
|
||||
"passwordConfirm":"12345679"
|
||||
}`,
|
||||
[]string{"passwordConfirm"},
|
||||
},
|
||||
// valid token and password
|
||||
{
|
||||
`{
|
||||
"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZXhwIjoyMjA4OTg1MjYxfQ.R_4FOSUHIuJQ5Crl3PpIPCXMsoHzuTaNlccpXg_3FOg",
|
||||
"password":"12345678",
|
||||
"passwordConfirm":"12345678"
|
||||
}`,
|
||||
[]string{},
|
||||
},
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
form := forms.NewRecordPasswordResetConfirm(testApp, authCollection)
|
||||
|
||||
// load data
|
||||
loadErr := json.Unmarshal([]byte(s.jsonData), form)
|
||||
if loadErr != nil {
|
||||
t.Errorf("(%d) Failed to load form data: %v", i, loadErr)
|
||||
continue
|
||||
}
|
||||
|
||||
interceptorCalls := 0
|
||||
interceptor := func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
|
||||
return func(r *models.Record) error {
|
||||
interceptorCalls++
|
||||
return next(r)
|
||||
}
|
||||
}
|
||||
|
||||
record, submitErr := form.Submit(interceptor)
|
||||
|
||||
// parse errors
|
||||
errs, ok := submitErr.(validation.Errors)
|
||||
if !ok && submitErr != nil {
|
||||
t.Errorf("(%d) Failed to parse errors %v", i, submitErr)
|
||||
continue
|
||||
}
|
||||
|
||||
// check interceptor calls
|
||||
expectInterceptorCalls := 1
|
||||
if len(s.expectedErrors) > 0 {
|
||||
expectInterceptorCalls = 0
|
||||
}
|
||||
if interceptorCalls != expectInterceptorCalls {
|
||||
t.Errorf("[%d] Expected interceptor to be called %d, got %d", i, expectInterceptorCalls, interceptorCalls)
|
||||
}
|
||||
|
||||
// check errors
|
||||
if len(errs) > len(s.expectedErrors) {
|
||||
t.Errorf("(%d) Expected error keys %v, got %v", i, s.expectedErrors, errs)
|
||||
}
|
||||
for _, k := range s.expectedErrors {
|
||||
if _, ok := errs[k]; !ok {
|
||||
t.Errorf("(%d) Missing expected error key %q in %v", i, k, errs)
|
||||
}
|
||||
}
|
||||
|
||||
if len(errs) > 0 || len(s.expectedErrors) > 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
claims, _ := security.ParseUnverifiedJWT(form.Token)
|
||||
tokenRecordId := claims["id"]
|
||||
|
||||
if record.Id != tokenRecordId {
|
||||
t.Errorf("(%d) Expected record with id %s, got %v", i, tokenRecordId, record)
|
||||
}
|
||||
|
||||
if !record.LastResetSentAt().IsZero() {
|
||||
t.Errorf("(%d) Expected record.LastResetSentAt to be empty, got %v", i, record.LastResetSentAt())
|
||||
}
|
||||
|
||||
if !record.ValidatePassword(form.Password) {
|
||||
t.Errorf("(%d) Expected the record password to have been updated to %q", i, form.Password)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordPasswordResetConfirmInterceptors(t *testing.T) {
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
|
||||
authCollection, err := testApp.Dao().FindCollectionByNameOrId("users")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
authRecord, err := testApp.Dao().FindAuthRecordByEmail("users", "test@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
form := forms.NewRecordPasswordResetConfirm(testApp, authCollection)
|
||||
form.Token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZXhwIjoyMjA4OTg1MjYxfQ.R_4FOSUHIuJQ5Crl3PpIPCXMsoHzuTaNlccpXg_3FOg"
|
||||
form.Password = "1234567890"
|
||||
form.PasswordConfirm = "1234567890"
|
||||
interceptorTokenKey := authRecord.TokenKey()
|
||||
testErr := errors.New("test_error")
|
||||
|
||||
interceptor1Called := false
|
||||
interceptor1 := func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
|
||||
return func(record *models.Record) error {
|
||||
interceptor1Called = true
|
||||
return next(record)
|
||||
}
|
||||
}
|
||||
|
||||
interceptor2Called := false
|
||||
interceptor2 := func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
|
||||
return func(record *models.Record) error {
|
||||
interceptorTokenKey = record.TokenKey()
|
||||
interceptor2Called = true
|
||||
return testErr
|
||||
}
|
||||
}
|
||||
|
||||
_, submitErr := form.Submit(interceptor1, interceptor2)
|
||||
if submitErr != testErr {
|
||||
t.Fatalf("Expected submitError %v, got %v", testErr, submitErr)
|
||||
}
|
||||
|
||||
if !interceptor1Called {
|
||||
t.Fatalf("Expected interceptor1 to be called")
|
||||
}
|
||||
|
||||
if !interceptor2Called {
|
||||
t.Fatalf("Expected interceptor2 to be called")
|
||||
}
|
||||
|
||||
if interceptorTokenKey == authRecord.TokenKey() {
|
||||
t.Fatalf("Expected the form model to be filled before calling the interceptors")
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user