Merge branch '3.0'

Conflicts:
	admin/code/CMSProfileController.php
	composer.json
	tests/model/DataObjectTest.php
This commit is contained in:
Ingo Schommer 2012-11-22 23:51:28 +01:00
commit c55c7c33f8
51 changed files with 1397 additions and 359 deletions

View File

@ -1,6 +1,6 @@
## SilverStripe Framework
[![Build Status](https://secure.travis-ci.org/silverstripe/sapphire.png)](http://travis-ci.org/silverstripe/sapphire)
[![Build Status](https://secure.travis-ci.org/silverstripe/sapphire.png?branch=3.0)](https://travis-ci.org/silverstripe/sapphire)
PHP5 framework forming the base for the SilverStripe CMS ([http://silverstripe.org](http://silverstripe.org)).
Requires a [`silverstripe-installer`](http://github.com/silverstripe/silverstripe-installer) base project. Typically used alongside the [`cms`](http://github.com/silverstripe/silverstripe-cms) module.
@ -28,7 +28,7 @@ For other ways to contribute, see the [code contribution guidelines](http://doc.
## License ##
Copyright (c) 2007-2011, SilverStripe Limited - www.silverstripe.com
Copyright (c) 2007-2012, SilverStripe Limited - www.silverstripe.com
All rights reserved.
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:

View File

@ -13,7 +13,7 @@ HtmlEditorConfig::get('cms')->setOptions(array(
'use_native_selects' => false,
'valid_elements' => "@[id|class|style|title],a[id|rel|rev|dir|tabindex|accesskey|type|name|href|target|title"
. "|class],-strong/-b[class],-em/-i[class],-strike[class],-u[class],#p[id|dir|class|align|style],-ol[class],"
. "-ul[class],-li[class],br,img[id|dir|longdesc|usemap|class|src|border|alt=|title|width|height|align],"
. "-ul[class],-li[class],br,img[id|dir|longdesc|usemap|class|src|border|alt=|title|width|height|align|data*],"
. "-sub[class],-sup[class],-blockquote[dir|class],"
. "-table[border=0|cellspacing|cellpadding|width|height|class|align|summary|dir|id|style],"
. "-tr[id|dir|class|rowspan|width|height|align|valign|bgcolor|background|bordercolor|style],"
@ -25,7 +25,7 @@ HtmlEditorConfig::get('cms')->setOptions(array(
. "-h4[id|dir|class|align|style],-h5[id|dir|class|align|style],-h6[id|dir|class|align|style],hr[class],"
. "dd[id|class|title|dir],dl[id|class|title|dir],dt[id|class|title|dir],@[id,style,class]",
'extended_valid_elements' => "img[class|src|alt|title|hspace|vspace|width|height|align|onmouseover|onmouseout|name"
. "|usemap],iframe[src|name|width|height|align|frameborder|marginwidth|marginheight|scrolling],"
. "|usemap|data*],iframe[src|name|width|height|align|frameborder|marginwidth|marginheight|scrolling],"
. "object[width|height|data|type],param[name|value],map[class|name|id],area[shape|coords|href|target|alt]",
'spellchecker_rpc_url' => THIRDPARTY_DIR . '/tinymce-spellchecker/rpc.php'
));

View File

@ -2,7 +2,9 @@
class CMSProfileController extends LeftAndMain {
static $url_segment = 'myprofile';
static $menu_title = 'My Profile';
static $required_permission_codes = false;
static $tree_class = 'Member';

View File

@ -1371,6 +1371,30 @@ class LeftAndMain extends Controller implements PermissionProvider {
public function SiteConfig() {
return (class_exists('SiteConfig')) ? SiteConfig::current_site_config() : null;
}
/**
* The href for the anchor on the Silverstripe logo.
* Set by calling LeftAndMain::set_application_link()
*
* @var String
*/
static $application_link = 'http://www.silverstripe.org/';
/**
* Sets the href for the anchor on the Silverstripe logo in the menu
*
* @param String $link
*/
public static function set_application_link($link) {
self::$application_link = $link;
}
/**
* @return String
*/
public function ApplicationLink() {
return self::$application_link;
}
/**
* The application name. Customisable by calling

View File

@ -928,14 +928,14 @@ jQuery.noConflict();
if(!this.data('uiTabs')) this.tabs({
active: (activeTab.index() != -1) ? activeTab.index() : 0,
beforeLoad: function(e, settings) {
beforeLoad: function(e, ui) {
// Overwrite ajax loading to use CMS logic instead
var makeAbs = $.path.makeUrlAbsolute,
baseUrl = $('base').attr('href'),
isSame = (makeAbs(settings.url, baseUrl) == makeAbs(document.location.href));
isSame = (makeAbs(ui.ajaxSettings.url, baseUrl) == makeAbs(document.location.href));
if(!isSame) $('.cms-container').loadPanel(settings.url);
$(this).tabs('select', settings.tab.index());
if(!isSame) $('.cms-container').loadPanel(ui.ajaxSettings.url);
$(this).tabs('select', ui.tab.index());
return false;
},

View File

@ -1,7 +1,7 @@
<div class="cms-menu cms-panel cms-panel-layout west" id="cms-menu" data-layout-type="border">
<div class="cms-logo-header north">
<div class="cms-logo">
<a href="http://www.silverstripe.org/" target="_blank" title="SilverStripe (Version - $CMSVersion)">
<a href="$ApplicationLink" target="_blank" title="SilverStripe (Version - $CMSVersion)">
SilverStripe <% if CMSVersion %><abbr class="version">$CMSVersion</abbr><% end_if %>
</a>
<span><% if SiteConfig %>$SiteConfig.Title<% else %>$ApplicationName<% end_if %></span>

View File

@ -23,5 +23,8 @@
"branch-alias": {
"dev-master": "3.1.x-dev"
}
},
"autoload": {
"classmap": ["tests/behat/features/bootstrap"]
}
}

View File

@ -275,10 +275,16 @@ $flush = (isset($_GET['flush']) || isset($_REQUEST['url']) && (
));
$manifest = new SS_ClassManifest(BASE_PATH, false, $flush);
// Register SilverStripe's class map autoload
$loader = SS_ClassLoader::instance();
$loader->registerAutoloader();
$loader->pushManifest($manifest);
// Fall back to Composer's autoloader (e.g. for PHPUnit), if composer is used
if(file_exists(BASE_PATH . '/vendor/autoload.php')) {
require_once BASE_PATH . '/vendor/autoload.php';
}
// Now that the class manifest is up, load the configuration
$configManifest = new SS_ConfigManifest(BASE_PATH, false, $flush);
Config::inst()->pushConfigManifest($configManifest);

View File

@ -0,0 +1,44 @@
# 3.0.3-rc2 (2012-11-16)
## Overview
3.0.3 provides security fixes, bugfixes and a number of minor enhancements since 3.0.2.
Upgrading from 3.0.x should be a straightforward matter of dropping in the new release,
with the exception noted below.
## Upgrading
Impact of the upgrade:
* Reset password email links generated prior to 3.0.3 will cease to work.
* Users who use the "remember me" login feature will have to log in again.
API changes related to the below security patch:
* `Member::generateAutologinHash` is deprecated. You can no longer get the autologin token from `AutoLoginHash` field in `Member`. Instead use the return value of the `Member::generateAutologinTokenAndStoreHash` and do not persist it.
* `Security::getPasswordResetLink` now requires `Member` object as the first parameter. The password reset URL GET parameters have changed from only `h` (for hash) to `m` (for member ID) and `t` (for plaintext token).
* `RandomGenerator::generateHash` will be deprecated with 3.1. Rename the function call to `RandomGenerator::randomToken`.
### Security: Hash autologin tokens before storing in the database.
Severity: Moderate
Autologin tokens (remember me and reset password) are stored in the database as a plain text.
If attacker obtained the database he would be able to gain access to accounts that have requested a password change, or have "remember me" enabled.
## Changelog
### API Changes
* 2012-11-16 [0dd97a3](https://github.com/silverstripe/sapphire/commit/0dd97a3) Form#loadDataFrom 2nd arg now sets how existing field data is merged with new data (Hamish Friedlander)
* 2012-11-08 [a8b0e44](https://github.com/silverstripe/sapphire/commit/a8b0e44) Hash autologin tokens before storing in the database. (Mateusz Uzdowski)
### Bugfixes
* 2012-11-16 [7315be4](https://github.com/silverstripe/sapphire/commit/7315be4) default values from DataObject not showing in GridField details form (Hamish Friedlander)
* 2012-11-15 [78ab9d3](https://github.com/silverstripe/sapphire/commit/78ab9d3) Video embed from Add Media Feature no longer works (open #8033) (stojg)
### Other
* 2012-11-09 [05a44e8](https://github.com/silverstripe/sapphire/commit/05a44e8) Correct branch for Travis build status image (Ingo Schommer)

View File

@ -3,51 +3,26 @@
This guide helps you to run [PHPUnit](http://phpunit.de) tests in your SilverStripe project.
See "[Testing](/topics/testing)" for an overview on how to create unit tests.
## Should I execute through "sake dev/tests" or "phpunit"?
Short answer: Both are valid ways.
The `sake` executable that comes with SilverStripe can trigger a customized
"[api:TestRunner]" class that handles the PHPUnit configuration and output formatting.
It's tyically invoked to run all tests through `sake dev/tests/all`,
a single test with `sake dev/tests/MyTestClass`, or tests for a module with `sake dev/tests/module/mymodulename`.
While the custom test runner a handy tool, its also more limited than using `phpunit` directly,
particularly around formatting test output.
The `phpunit` executable uses a SilverStripe bootstrapper to autoload classes,
but handles its own test class retrieval, output formatting and other configuration.
It can format output in common structured formats used by "continuous integration" servers.
If you're using [phpUnderControl](http://phpundercontrol.org/) or a similar tool,
you will most likely need the `--log-junit` and `--coverage-xml` flags that are not available through `sake`.
All command-line arguments are documented on [phpunit.de](http://www.phpunit.de/manual/current/en/textui.html).
## Usage of "phpunit" executable
* `phpunit`: Runs all tests in all folders
* `phpunit framework/tests/`: Run all tests of the framework module
* `phpunit framework/tests/filesystem`: Run all filesystem tests within the framework module
* `phpunit framework/tests/filesystem/FolderTest.php`: Run a single test
* `phpunit framework/tests '' flush=all`: Run tests with optional `$_GET` parameters (you need an empty second argument)
## Coverage reports
PHPUnit can generate code coverage reports for you ([docs](http://www.phpunit.de/manual/current/en/code-coverage-analysis.html)):
* `phpunit --coverage-html assets/coverage-report`: Generate coverage report for the whole project
* `phpunit --coverage-html assets/coverage-report mysite/tests/`: Generate coverage report for the "mysite" module
## Customizing phpunit.xml.dist
Typically, only your own custom PHP code in your project should be regarded when
producing these reports. Here's how you would exclude some `thirdparty/` directories:
The `phpunit` executable can be configured by commandline arguments or through an XML file.
File-based configuration has the advantage of enforcing certain rules across
test executions (e.g. excluding files from code coverage reports), and of course this
information can be version controlled and shared with other team members.
SilverStripe comes with a default `phpunit.xml.dist` that you can use as a starting point.
Copy the file into a new `phpunit.xml` and customize to your needs - PHPUnit will auto-detect
its existence, and prioritize it over the default file.
There's nothing stopping you from creating multiple XML files (see the `--configuration` flag in [PHPUnit documentation](http://www.phpunit.de/manual/current/en/textui.html)).
For example, you could have a `phpunit-unit-tests.xml` and `phpunit-functional-tests.xml` file (see below).
<filter>
<blacklist>
<directory suffix=".php">framework/dev/</directory>
<directory suffix=".php">framework/thirdparty/</directory>
<directory suffix=".php">cms/thirdparty/</directory>
<!-- Add your custom rules here -->
<directory suffix=".php">mysite/thirdparty/</directory>
</blacklist>
</filter>
## Running unit and functional tests separately
@ -76,24 +51,6 @@ You can run with this XML configuration simply by invoking `phpunit --configurat
The same effect can be achieved with the `--group` argument and some PHPDoc (see [phpunit.de](http://www.phpunit.de/manual/current/en/appendixes.configuration.html#appendixes.configuration.groups)).
## Adding/removing files for code coverage reports
Not all PHP code in your project should be regarded when producing [code coverage reports](http://www.phpunit.de/manual/current/en/code-coverage-analysis.html).
This applies for all thirdparty code
<filter>
<blacklist>
<directory suffix=".php">framework/dev/</directory>
<directory suffix=".php">framework/thirdparty/</directory>
<directory suffix=".php">cms/thirdparty/</directory>
<!-- Add your custom rules here -->
<directory suffix=".php">mysite/thirdparty/</directory>
</blacklist>
</filter>
See [phpunit.de](http://www.phpunit.de/manual/current/en/appendixes.configuration.html#appendixes.configuration.blacklist-whitelist) for more information.
## Speeding up your test execution with the SQLite3 module
Test execution can easily take a couple of minutes for a full run,

View File

@ -12,21 +12,29 @@ For more information about Composer, visit [its website](http://getcomposer.org/
## Installing composer
To install Composer, run the following command from your command-line.
To install Composer, run the following commands from your command-line.
# Download composer.phar
curl -s https://getcomposer.org/installer | php
Or [download composer.phar](http://getcomposer.org/composer.phar) manually.
# Move to your path
sudo mv composer.phar /usr/bin/composer
You can then run Composer commands by calling `php composer.phar`. For example:
Or [download composer.phar](http://getcomposer.org/composer.phar) manually, and rename `composer.phar` as `composer`, and put it in your path. On Windows, you should call the file `composer.bat`.
You can then run Composer commands by calling `composer`. For example:
composer help
<div class="hint" markdown="1">
It is also possible to keep `composer.phar` out of your path, for example, to put it in your project root. Every command would then start with `php composer.phar` instead of `composer`. This is handy if need to keep your installation isolated from the rest of your computer's set-up, but we recommend putting composer into the path for most people.
</div>
php composer.phar help
## Create a new site
Composer can create a new site for you, using the installer as a template. To do so, run this:
php composer.phar create-project silverstripe/installer ./my/website/folder 3.0.2.1
composer create-project silverstripe/installer ./my/website/folder 3.0.2.1
`./my/website/folder` should be the root directory where your site will live. For example, on OS X, you might use a subdirectory of `~/Sites`.
@ -37,17 +45,49 @@ browser, and the installation process will be completed.
## Adding modules to your project
Composer isn't only used to download SilverStripe CMS: it can also be used to manage all the modules. Installing a module can be done with the following command:
Composer isn't only used to download SilverStripe CMS, it can also be used to manage all SilverStripe modules. Installing a module can be done with the following command:
php composer.phar require silverstripe/forum:*
composer require silverstripe/forum:*
This command has two parts. First is `silverstripe/forum`. This is the name of the package. You can find other packages with the following command:
php composer.phar search silverstripe
composer search silverstripe
This will return a list of package names of the forum `vendor/package`. If you prefer, you can search for pacakges on [packagist.org](https://packagist.org/search/?q=silverstripe).
The second part, `*`, is a version string. `*` is a good default: it will give you the latest version that works with the other modules you have installed. Alternatively, you can specificy a specific version, or a constraint such as `>=3.0`. For more information, read the [Composer documentation](http://getcomposer.org/doc/01-basic-usage.md#the-require-key).
The second part after the colon, `*`, is a version string. `*` is a good default: it will give you the latest version that works with the other modules you have installed. Alternatively, you can specificy a specific version, or a constraint such as `>=3.0`. For more information, read the [Composer documentation](http://getcomposer.org/doc/01-basic-usage.md#the-require-key).
<div class="warning" markdown="1">
`master` is not a legal version string - it's a branch name. These are different things. The version string that would get you the branch is `dev-master`. The version string that would get you a numeric branch is a little different. The version string for the `3.0` branch is `3.0.x-dev`. But, frankly, maybe you should just use `*`.
</div>
## Updating dependencies
Except for the control code of the Voyager space probe, every piece of code in the universe gets updated from time to time. SilverStripe modules are no exception.
To get the latest updates of the modules in your project, run this command:
composer update
Updates to the required modules will be installed, and the `composer.lock` file will get updated with the specific commits of each of those.
## Deploying projects with Composer
When deploying projects with composer, you could just push the code and run `composer update`. However, this is risky. In particular, if you were referencing development dependencies and a change was made between your testing and your depoyment to production, you would end up deploying untested code. Not cool!
The `composer.lock` file helps with this. It references the specific commits that have been checked out, rather than the version string. You can run `composer install` to install dependencies from this rather than `composer.json`.
So, your deployment process, as it relates to Composer, should be as follows:
* Run `composer update` on your development version before you start whatever testing you have planned. Perform all the necessary testing.
* Check `composer.lock` into your repository.
* Deploy your project code base, using the deployment tool of your choice.
* Run the following command on your production version.
composer install
# Advanced usage
@ -60,21 +100,22 @@ To remove dependencies, or if you prefer seeing all your dependencies in a text
"description": "The SilverStripe Framework Installer",
"require": {
"php": ">=5.3.2",
"silverstripe/cms": "3.0.3",
"silverstripe/framework": "3.0.3",
"silverstripe/cms": "3.0.2.1",
"silverstripe/framework": "3.0.2.1",
"silverstripe-themes/simple": "*"
},
"require-dev": {
"silverstripe/compass": "*",
"silverstripe/docsviewer": "*"
},
"minimum-stability": "dev"
}
To add modules, you should add more entries into the `"require"` section. For example, we might add the blog and forum modules. Be careful with the commas at the end of the lines!
Save your file, and then run the following command to refresh the installed packages:
php composer.phar update
composer update
## Working with project forks and unreleased modules
@ -107,9 +148,9 @@ This is how you do it:
* **Install the module as you would normally.** Use the regular composer function - there are no special flags to use a fork. Your fork will be used in place of the package version.
php composer.phar require silverstipre/advancedworklow
composer require silverstripe/advancedworkflow
Composer will scan all of the repositories you list, collect meta-data about the packages within them, and use them in favour of the packages listed on packagist. To switch back to using the mainline version of the package, just remove your the `repositories` section from `composer.json` and run `php composer.phar update`.
Composer will scan all of the repositories you list, collect meta-data about the packages within them, and use them in favour of the packages listed on packagist. To switch back to using the mainline version of the package, just remove your the `repositories` section from `composer.json` and run `composer update`.
For more information, read the ["Repositories" chapter of the Composer documentation](http://getcomposer.org/doc/05-repositories.md).
@ -127,48 +168,29 @@ Open `composer.json`, and find the module's `require`. Then put `as (core versi
...
"require": {
"php": ">=5.3.2",
"silverstripe/cms": "3.0.3",
"silverstripe/cms": "3.0.2.1",
"silverstripe/framework": "dev-myproj as 3.0.x-dev",
"silverstripe-themes/simple": "*"
},
...
}
What is means is that when the `myproj` branch is checked out into a project, this will satisfy any dependencies that 3.0.x-dev would meet. So, if another module has `"silverstripe/framework": ">=3.0.0"` in its dependency list, it won't get a conflict.
What this means is that when the `myproj` branch is checked out into a project, this will satisfy any dependencies that 3.0.x-dev would meet. So, if another module has `"silverstripe/framework": ">=3.0.0"` in its dependency list, it won't get a conflict.
Both the version and the alias are specified as Composer versions, not branch names. For the relationship between branch/tag names and Composer vesrions, read [the relevant Composer documentation](http://getcomposer.org/doc/02-libraries.md#specifying-the-version).
This is not the only way to set things up in Composer. For more information on this topic, read the ["Aliases" chapter of the Composer documentation](http://getcomposer.org/doc/articles/aliases.md).
## Setting up an environment for working on SilverStripe
## Setting up an environment for contributing to SilverStripe
So you want to contribute to SilverStripe? Fantastic! There are a couple modules that are helpful
So you want to contribute to SilverStripe? Fantastic! You have to initialize your project from the latest development branch,
rather than a release tag. The process will take a bit longer, since all modules are checked out as full git repositories which you can work on.
composer create-project silverstripe/installer --dev ./my/website/folder 3.0.x-dev
The `--dev` flag will add a couple modules which are useful for SilverStripe development:
* The `compass` module will regenerate CSS if you update the SCSS files
* The `docsviewer` module will let you preview changes to the project documentation
By default, these modules aren't installed, but you can install them with a special version of composer's update command:
php composer.phar update --dev
## Creating a 'composer' binary
Composer is designed to be portable and not require installation in special locations of your computer. This is
useful in certain circumstances, but sometimes it's helpful simply to have composer installed in the path of your workstation.
To do this, we can make the composer download an executable script. Go to a directory in your path that you can write to. I have `~/bin` set up for this purpose. You could also go to `/usr/bin/` and log in as root.
cd ~/bin
Then download composer.phar to this directory and create a 1 line binary file
curl -s https://getcomposer.org/installer | php
mv composer.phar composer
chmod +x composer
Now check that it works:
composer help
composer list
In any of the commands above, you can replace `php composer.phar` with `composer`.
Note that you can also include those into an existing project by running `composer update --dev`.

View File

@ -166,9 +166,9 @@ in jQuery.entwine, we're trying to reuse library code wherever possible.
The most prominent example of this is the usage of [jQuery UI](http://jqueryui.com) for
dialogs and buttons.
The CMS includes the jQuery.entwine inspector. Press Ctrl+` to bring down the inspector.
The CMS includes the jQuery.entwine inspector. Press Ctrl+` ("backtick") to bring down the inspector.
You can then click on any element in the CMS to see which entwine methods are bound to
any particular element.
any particular element.
## JavaScript and CSS dependencies via Requirements and Ajax
@ -289,6 +289,11 @@ Keep in mind that the returned view isn't always decided upon when the Ajax requ
is fired, so the server might decide to change it based on its own logic,
sending back different `X-Pjax` headers and content.
On the client, you can set your preference through the `data-pjax-target` attributes
on links or through the `X-Pjax` header. For firing off an Ajax request that is
tracked in the browser history, use the `pjax` attribute on the state data.
$('.cms-container').loadPanel('admin/pages', null, {pjax: 'Content'});
## Ajax Redirects

View File

@ -130,6 +130,19 @@ For example, we might want to have a checkbox which limits search results to exp
}
}
To alter how the results are displayed (via `[api:GridField]`), you can also overload the `getEditForm()` method. For example, to add a new component.
:::php
class MyAdmin extends ModelAdmin {
// ...
public function getEditForm($id = null, $fields = null) {
$form = parent::getEditForm($id, $fields);
$gridField = $form->Fields()->fieldByName($this->sanitiseClassName($this->modelClass));
$gridField->getConfig()->addComponent(new GridFieldFilterHeader());
return $form;
}
}
## Managing Relationships
Has-one relationships are simply implemented as a `[api:DropdownField]` by default.

View File

@ -63,3 +63,6 @@ We can use string processing on the body of the response to then see if it fits
If you're testing for natural language responses like error messages, make sure to use [i18n](/topics/i18n) translations through
the *_t()* method to avoid tests failing when i18n is enabled.
Note that for a more highlevel testing approach, SilverStripe also supports
[behaviour-driven testing through Behat](https://github.com/silverstripe-labs/silverstripe-behat-extension). It interacts directly with your website or CMS interface by remote controlling an actual browser, driven by natural language assertions.

View File

@ -16,66 +16,92 @@ fundamental concepts that we build on in this documentation.
If you're more familiar with unit testing, but want a refresher of some of the concepts and terminology, you can browse
the [Testing Glossary](#glossary).
To get started now, follow the installation instructions below, and check
[Troubleshooting](/topics/testing/testing-guide-troubleshooting) in case you run into any problems.
## Installation
The framework has a required dependency on [PHPUnit](http://www.phpunit.de/) and an optional dependency on
[SimpleTest](http://simpletest.org/), the two premiere PHP testing frameworks.
### Via Composer
To run SilverStripe tests, you'll need to be able to access PHPUnit on your include path. First, you'll need to make sure
that you have the PEAR command line client installed. To test this out, type `pear help` at your prompt. You should
see a bunch of generic PEAR info. If it's not installed, you'll need to set it up first (see: [Getting Started with
PEAR](http://www.sitepoint.com/article/getting-started-with-pear/)) or else manually install PHPUnit (see: [Installation
instructions](http://www.phpunit.de/pocket_guide/3.3/en/installation.html)).
Unit tests are not included in the normal SilverStripe downloads,
you are expected to work with local git repositories
([installation instructions](/topics/installation/composer)).
The PHPUnit installation via PEAR is very straightforward.
You might have to perform the following commands as root or super user (sudo).
Once you've got the project up and running,
check out the additional requirements to run unit tests:
<del>We need a specific version of PHPUnit (3.3.x), as 3.4 or higher breaks our test runner (see [#4573](http://open.silverstripe.com/ticket/4573))</del>
composer update --dev
At your prompt, type the following commands:
The will install (among other things) the [PHPUnit](http://www.phpunit.de/) dependency,
which is the framework we're building our unit tests on.
Composer installs it alongside the required PHP classes into the `vendor/bin/` directory.
You can either use it through its full path (`vendor/bin/phpunit`), or symlink it
into the root directory of your website:
pear channel-discover pear.phpunit.de
pear channel-discover pear.symfony-project.com
pear install phpunit/PHPUnit
ln -s vendor/bin/phpunit phpunit
### Via PEAR
Alternatively, you can check out phpunit globally via the PEAR packanage manager
([instructions](https://github.com/sebastianbergmann/phpunit/)).
pear config-set auto_discover 1
pear install pear.phpunit.de/PHPUnit
## Running Tests
### Via the "phpunit" Binary on Command Line
The `phpunit` binary should be used from the root directory of your website.
# Runs all tests defined in phpunit.xml
phpunit
# Run all tests of a specific module
phpunit framework/tests/
# Run specific tests within a specific module
phpunit framework/tests/filesystem
# Run a specific test
phpunit framework/tests/filesystem/FolderTest.php
# Run tests with optional `$_GET` parameters (you need an empty second argument)
phpunit framework/tests '' flush=all
All command-line arguments are documented on
[phpunit.de](http://www.phpunit.de/manual/current/en/textui.html).
### Via the "sake" Wrapper on Command Line
The [sake](/topics/commandline) executable that comes with SilverStripe can trigger a customized
"[api:TestRunner]" class that handles the PHPUnit configuration and output formatting.
While the custom test runner a handy tool, its also more limited than using `phpunit` directly,
particularly around formatting test output.
# Run all tests
sake dev/tests/all
# Run all tests of a specific module (comma-separated)
sake dev/tests/module/framework,cms
# Run specific tests (comma-separated)
sake dev/tests/FolderTest,OtherTest
# Run tests with optional `$_GET` parameters
sake dev/tests/all flush=all
# Skip some tests
sake dev/tests/all SkipTests=MySkippedTest
### Via Web Browser
Go to the main test URL which will give you options for running various available test suites or individual tests on
their own:
Executing tests from the command line is recommended, since it most closely reflects
test runs in any automated testing environments. If for some reason you don't have
access to the command line, you can also run tests through the browser.
http://localhost/dev/tests
### Via Command Line
`cd` to the root level of your project and run [sake](/topics/commandline) (SilverStripe Make) to execute the tests:
/path/to/project$ sake dev/tests/all
### Partial Test Runs
Run specific tests:
dev/tests/MyTest,MyOtherTest
Run all tests in a module folder, e.g. "framework"
dev/tests/module/<modulename>
Skip certain tests
dev/tests/all SkipTests=MySkippedTest
## Writing Tests
Tests are written by creating subclasses of `[api:SapphireTest]`. You should put tests for your site in the
@ -92,18 +118,37 @@ You will generally write two different kinds of test classes.
Some people may note that we have used the same naming convention as Ruby on Rails.
## How To
Tutorials and recipes for creating tests using the SilverStripe framework:
* **[Create a SilverStripe Test](/topics/testing/create-silverstripe-test)**
* **[Create a Functional Test](/topics/testing/create-functional-test)**
* **[Test Outgoing Email Sending](/topics/testing/email-sending)**
## Configuration
### phpunit.xml
The `phpunit` executable can be configured by commandline arguments or through an XML file.
File-based configuration has the advantage of enforcing certain rules across
test executions (e.g. excluding files from code coverage reports), and of course this
information can be version controlled and shared with other team members.
**Note: This doesn't apply for running tests through the "sake" wrapper**
SilverStripe comes with a default `phpunit.xml.dist` that you can use as a starting point.
Copy the file into a new `phpunit.xml` and customize to your needs - PHPUnit will auto-detect
its existence, and prioritize it over the default file.
There's nothing stopping you from creating multiple XML files (see the `--configuration` flag in [PHPUnit documentation](http://www.phpunit.de/manual/current/en/textui.html)).
For example, you could have a `phpunit-unit-tests.xml` and `phpunit-functional-tests.xml` file (see below).
## Glossary {#glossary}
**Assertion:** A predicate statement that must be true when a test runs.
**Behat:** A behaviour-driven testing library used with SilverStripe as a higher-level
alternative to the `FunctionalTest` API, see [http://behat.org](http://behat.org).
**Test Case:** The atomic class type in most unit test frameworks. New unit tests are created by inheriting from the
base test case.

View File

@ -159,7 +159,7 @@ class Upload extends Controller {
}
if(file_exists($tmpFile['tmp_name']) && copy($tmpFile['tmp_name'], "$base/$relativeFilePath")) {
$this->file->ParentID = $parentFolder->ID;
$this->file->ParentID = $parentFolder ? $parentFolder->ID : 0;
// This is to prevent it from trying to rename the file
$this->file->Name = basename($relativeFilePath);
$this->file->write();

View File

@ -1014,6 +1014,10 @@ class Form extends RequestHandler {
return true;
}
const MERGE_DEFAULT = 0;
const MERGE_CLEAR_MISSING = 1;
const MERGE_IGNORE_FALSEISH = 2;
/**
* Load data from the given DataObject or array.
* It will call $object->MyField to get the value of MyField.
@ -1032,20 +1036,43 @@ class Form extends RequestHandler {
* @uses FormField->setValue()
*
* @param array|DataObject $data
* @param boolean $clearMissingFields By default, fields which don't match
* a property or array-key of the passed {@link $data} argument are "left alone",
* meaning they retain any previous values (if present). If this flag is set to true,
* those fields are overwritten with null regardless if they have a match in {@link $data}.
* @param $fieldList An optional list of fields to process. This can be useful when you have a
* @param int $mergeStrategy
* For every field, {@link $data} is interogated whether it contains a relevant property/key, and
* what that property/key's value is.
*
* By default, if {@link $data} does contain a property/key, the fields value is always replaced by {@link $data}'s
* value, even if that value is null/false/etc. Fields which don't match any property/key in {@link $data} are
* "left alone", meaning they retain any previous value.
*
* You can pass a bitmask here to change this behaviour.
*
* Passing CLEAR_MISSING means that any fields that don't match any property/key in
* {@link $data} are cleared.
*
* Passing IGNORE_FALSEISH means that any false-ish value in {@link $data} won't replace
* a field's value.
*
* For backwards compatibility reasons, this parameter can also be set to === true, which is the same as passing
* CLEAR_MISSING
*
* @param $fieldList An optional list of fields to process. This can be useful when you have a
* form that has some fields that save to one object, and some that save to another.
* @return Form
*/
public function loadDataFrom($data, $clearMissingFields = false, $fieldList = null) {
public function loadDataFrom($data, $mergeStrategy = 0, $fieldList = null) {
if(!is_object($data) && !is_array($data)) {
user_error("Form::loadDataFrom() not passed an array or an object", E_USER_WARNING);
return $this;
}
// Handle the backwards compatible case of passing "true" as the second argument
if ($mergeStrategy === true) {
$mergeStrategy = self::MERGE_CLEAR_MISSING;
}
else if ($mergeStrategy === false) {
$mergeStrategy = 0;
}
// if an object is passed, save it for historical reference through {@link getRecord()}
if(is_object($data)) $this->record = $data;
@ -1059,37 +1086,50 @@ class Form extends RequestHandler {
// First check looks for (fieldname)_unchanged, an indicator that we shouldn't overwrite the field value
if(is_array($data) && isset($data[$name . '_unchanged'])) continue;
// get value in different formats
$hasObjectValue = false;
if(
is_object($data)
&& (
isset($data->$name)
|| $data->hasMethod($name)
|| ($data->hasMethod('hasField') && $data->hasField($name))
)
) {
// We don't actually call the method because it might be slow.
// In a later release, relation methods will just return references to the query that should be
// executed, and so we will be able to safely pass the return value of the relation method to the
// first argument of setValue
$val = $data->__get($name);
$hasObjectValue = true;
} else if(strpos($name,'[') && is_array($data) && !isset($data[$name])) {
// if field is in array-notation, we need to resolve the array-structure PHP creates from query-strings
preg_match('/' . addcslashes($name,'[]') . '=([^&]*)/', urldecode(http_build_query($data)), $matches);
$val = isset($matches[1]) ? $matches[1] : null;
} elseif(is_array($data) && array_key_exists($name, $data)) {
// else we assume its a simple keyed array
$val = $data[$name];
} else {
$val = null;
// Does this property exist on $data?
$exists = false;
// The value from $data for this field
$val = null;
if(is_object($data)) {
$exists = (
isset($data->$name) ||
$data->hasMethod($name) ||
($data->hasMethod('hasField') && $data->hasField($name))
);
if ($exists) {
$val = $data->__get($name);
}
}
else if(is_array($data)){
if(array_key_exists($name, $data)) {
$exists = true;
$val = $data[$name];
}
// If field is in array-notation we need to access nested data
else if(strpos($name,'[')) {
// First encode data using PHP's method of converting nested arrays to form data
$flatData = urldecode(http_build_query($data));
// Then pull the value out from that flattened string
preg_match('/' . addcslashes($name,'[]') . '=([^&]*)/', $flatData, $matches);
if (isset($matches[1])) {
$exists = true;
$val = $matches[1];
}
}
}
// save to the field if either a value is given, or loading of blank/undefined values is forced
if(isset($val) || $hasObjectValue || $clearMissingFields) {
// pass original data as well so composite fields can act on the additional information
if($exists){
if ($val != false || ($mergeStrategy & self::MERGE_IGNORE_FALSEISH) != self::MERGE_IGNORE_FALSEISH){
// pass original data as well so composite fields can act on the additional information
$field->setValue($val, $data);
}
}
else if(($mergeStrategy & self::MERGE_CLEAR_MISSING) == self::MERGE_CLEAR_MISSING){
$field->setValue($val, $data);
}
}

View File

@ -272,8 +272,8 @@ class HtmlEditorField_Toolbar extends RequestHandler {
public function forTemplate() {
return sprintf(
'<div id="cms-editor-dialogs" data-url-linkform="%s" data-url-mediaform="%s"></div>',
Controller::join_links($this->controller->Link($this->name), 'LinkForm', 'forTemplate'),
Controller::join_links($this->controller->Link($this->name), 'MediaForm', 'forTemplate')
Controller::join_links($this->controller->Link(), $this->name, 'LinkForm', 'forTemplate'),
Controller::join_links($this->controller->Link(), $this->name, 'MediaForm', 'forTemplate')
);
}

View File

@ -296,6 +296,7 @@ class GridFieldDetailForm_ItemRequest extends RequestHandler {
->setAttribute('data-icon', 'accept'));
$actions->push(FormAction::create('doDelete', _t('GridFieldDetailForm.Delete', 'Delete'))
->setUseButtonTag(true)
->addExtraClass('ss-ui-action-destructive'));
}else{ // adding new record
@ -325,9 +326,8 @@ class GridFieldDetailForm_ItemRequest extends RequestHandler {
$actions,
$this->component->getValidator()
);
if($this->record->ID !== 0) {
$form->loadDataFrom($this->record);
}
$form->loadDataFrom($this->record, $this->record->ID == 0 ? Form::MERGE_IGNORE_FALSEISH : Form::MERGE_DEFAULT);
// TODO Coupling with CMS
$toplevelController = $this->getToplevelController();
@ -377,10 +377,10 @@ class GridFieldDetailForm_ItemRequest extends RequestHandler {
} elseif($this->popupController->hasMethod('Breadcrumbs')) {
$parents = $this->popupController->Breadcrumbs(false)->items;
$backlink = array_pop($parents)->Link;
} else {
$backlink = $toplevelController->Link();
}
}
}
if(!$backlink) $backlink = $toplevelController->Link();
return $backlink;
}

View File

@ -654,6 +654,7 @@ class i18n extends Object implements TemplateGlobalProvider {
'zh_yue' => array('Chinese (Cantonese)', '&#24291;&#26481;&#35441; [&#24191;&#19996;&#35805;]'),
'zh_cmn' => array('Chinese (Mandarin)', '&#26222;&#36890;&#35441; [&#26222;&#36890;&#35805;]'),
'hr' => array('Croatian', 'Hrvatski'),
'zh' => array('Chinese','中国的'),
'cs' => array('Czech', '&#x010D;e&#353;tina'),
'cy' => array('Welsh', 'Welsh/Cymraeg'),
'da' => array('Danish', 'dansk'),
@ -744,6 +745,7 @@ class i18n extends Object implements TemplateGlobalProvider {
'bn_BD' => array('Bengali', '&#2476;&#2494;&#2434;&#2482;&#2494;'),
'bg_BG' => array('Bulgarian', '&#1073;&#1098;&#1083;&#1075;&#1072;&#1088;&#1089;&#1082;&#1080;'),
'ca_ES' => array('Catalan', 'catal&agrave;'),
'zh_CN' => array('Chinese','中国的'),
'zh_yue' => array('Chinese (Cantonese)', '&#24291;&#26481;&#35441; [&#24191;&#19996;&#35805;]'),
'zh_cmn' => array('Chinese (Mandarin)', '&#26222;&#36890;&#35441; [&#26222;&#36890;&#35805;]'),
'hr_HR' => array('Croatian', 'Hrvatski'),

View File

@ -1,6 +1,7 @@
en:
AssetAdmin:
ALLOWEDEXTS: 'Allowed extensions'
ADDFILES: 'Add files'
EditOrgMenu: 'Edit &amp; organize'
NEWFOLDER: NewFolder
AssetTableField:
CREATED: 'First uploaded'
@ -215,6 +216,8 @@ en:
Deleted: 'Deleted %s %s'
Save: Save
Saved: 'Saved %s %s'
GridFieldEditButton.ss:
EDIT: Edit
GridFieldItemEditView.ss:
'Go back': 'Go back'
Group:
@ -343,6 +346,7 @@ en:
NEWPASSWORD: 'New Password'
PASSWORD: Password
PLURALNAME: Members
PROFILESAVESUCCESS: 'Successfully saved.'
REMEMBERME: 'Remember me next time?'
SINGULARNAME: Member
SUBJECTPASSWORDCHANGED: 'Your password has been changed'

View File

@ -19,7 +19,7 @@ class ArrayList extends ViewableData implements SS_List, SS_Filterable, SS_Sorta
* @param array $items - an initial array to fill this object with
*/
public function __construct(array $items = array()) {
$this->items = $items;
$this->items = array_values($items);
parent::__construct();
}
@ -137,9 +137,14 @@ class ArrayList extends ViewableData implements SS_List, SS_Filterable, SS_Sorta
* @param mixed $item
*/
public function remove($item) {
$renumberKeys = false;
foreach ($this->items as $key => $value) {
if ($item === $value) unset($this->items[$key]);
if ($item === $value) {
$renumberKeys = true;
unset($this->items[$key]);
}
}
if($renumberKeys) $this->items = array_values($this->items);
}
/**
@ -176,16 +181,20 @@ class ArrayList extends ViewableData implements SS_List, SS_Filterable, SS_Sorta
*/
public function removeDuplicates($field = 'ID') {
$seen = array();
$renumberKeys = false;
foreach ($this->items as $key => $item) {
$value = $this->extractValue($item, $field);
if (array_key_exists($value, $seen)) {
$renumberKeys = true;
unset($this->items[$key]);
}
$seen[$value] = true;
}
if($renumberKeys) $this->items = array_values($this->items);
}
/**
@ -473,7 +482,6 @@ class ArrayList extends ViewableData implements SS_List, SS_Filterable, SS_Sorta
}
}
$itemsToKeep = array();
$hitsRequiredToRemove = count($removeUs);
$matches = array();
@ -488,13 +496,17 @@ class ArrayList extends ViewableData implements SS_List, SS_Filterable, SS_Sorta
}
$keysToRemove = array_keys($matches,$hitsRequiredToRemove);
// TODO 3.1: This currently mutates existing array
$list = /* clone */ $this;
foreach($keysToRemove as $itemToRemoveIdx){
$list->remove($this->items[$itemToRemoveIdx]);
$itemsToKeep = array();
foreach($this->items as $key => $value) {
if(!in_array($key, $keysToRemove)) {
$itemsToKeep[] = $value;
}
}
// TODO 3.1: This currently mutates existing array
$list = /* clone */ $this;
$list->items = $itemsToKeep;
return $list;
}

View File

@ -145,9 +145,11 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity
*/
public static $cache_has_own_table = array();
public static $cache_has_own_table_field = array();
protected static $_cache_db = array();
protected static $_cache_get_one;
protected static $_cache_get_class_ancestry;
protected static $_cache_composite_fields = array();
protected static $_cache_composite_fields = array();
protected static $_cache_custom_database_fields = array();
protected static $_cache_field_labels = array();
/**
@ -226,8 +228,12 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity
* @return array Map of fieldname to specification, similiar to {@link DataObject::$db}.
*/
public static function custom_database_fields($class) {
if(isset(self::$_cache_custom_database_fields[$class])) {
return self::$_cache_custom_database_fields[$class];
}
$fields = Config::inst()->get($class, 'db', Config::UNINHERITED);
foreach(self::composite_fields($class, false) as $fieldName => $fieldClass) {
// Remove the original fieldname, it's not an actual database column
unset($fields[$fieldName]);
@ -244,8 +250,12 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity
if($hasOne) foreach(array_keys($hasOne) as $field) {
$fields[$field . 'ID'] = 'ForeignKey';
}
return (array)$fields;
$output = (array) $fields;
self::$_cache_custom_database_fields[$class] = $output;
return $output;
}
/**
@ -1561,23 +1571,28 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity
continue;
}
if(isset(self::$_cache_db[$class])) {
$dbItems = self::$_cache_db[$class];
} else {
$dbItems = (array) Config::inst()->get($class, 'db', Config::UNINHERITED);
self::$_cache_db[$class] = $dbItems;
}
if($fieldName) {
$db = Config::inst()->get($class, 'db', Config::UNINHERITED);
if(isset($db[$fieldName])) {
return $db[$fieldName];
if(isset($dbItems[$fieldName])) {
return $dbItems[$fieldName];
}
} else {
$newItems = (array)Config::inst()->get($class, 'db', Config::UNINHERITED);
// Validate the data
foreach($newItems as $k => $v) {
foreach($dbItems as $k => $v) {
if(!is_string($k) || is_numeric($k) || !is_string($v)) {
user_error("$class::\$db has a bad entry: "
. var_export($k,true). " => " . var_export($v,true) . ". Each map key should be a"
. " property name, and the map value should be the property type.", E_USER_ERROR);
}
}
$items = isset($items) ? array_merge((array)$items, $newItems) : $newItems;
$items = isset($items) ? array_merge((array) $items, $dbItems) : $dbItems;
}
}
@ -2798,8 +2813,10 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity
public static function reset() {
DataObject::$cache_has_own_table = array();
DataObject::$cache_has_own_table_field = array();
DataObject::$_cache_db = array();
DataObject::$_cache_get_one = array();
DataObject::$_cache_composite_fields = array();
DataObject::$_cache_custom_database_fields = array();
DataObject::$_cache_get_class_ancestry = array();
DataObject::$_cache_field_labels = array();
}

View File

@ -1073,7 +1073,10 @@ class SQLQuery {
public function lastRow() {
$query = clone $this;
$offset = $this->limit ? $this->limit['start'] : 0;
$query->setLimit(1, $this->count() + $offset - 1);
// Limit index to start in case of empty results
$index = max($this->count() + $offset - 1, 0);
$query->setLimit(1, $index);
return $query;
}

View File

@ -12,11 +12,11 @@ class Member extends DataObject implements TemplateGlobalProvider {
'Surname' => 'Varchar',
'Email' => 'Varchar(256)', // See RFC 5321, Section 4.5.3.1.3.
'Password' => 'Varchar(160)',
'RememberLoginToken' => 'Varchar(50)',
'RememberLoginToken' => 'Varchar(160)', // Note: this currently holds a hash, not a token.
'NumVisit' => 'Int',
'LastVisited' => 'SS_Datetime',
'Bounced' => 'Boolean', // Note: This does not seem to be used anywhere.
'AutoLoginHash' => 'Varchar(50)',
'AutoLoginHash' => 'Varchar(160)',
'AutoLoginExpired' => 'SS_Datetime',
// This is an arbitrary code pointing to a PasswordEncryptor instance,
// not an actual encryption algorithm.
@ -322,9 +322,11 @@ class Member extends DataObject implements TemplateGlobalProvider {
$this->NumVisit++;
if($remember) {
// Store the hash and give the client the cookie with the token.
$generator = new RandomGenerator();
$token = $generator->generateHash('sha1');
$this->RememberLoginToken = $token;
$token = $generator->randomToken('sha1');
$hash = $this->encryptWithUserSettings($token);
$this->RememberLoginToken = $hash;
Cookie::set('alc_enc', $this->ID . ':' . $token, 90, null, null, null, true);
} else {
$this->RememberLoginToken = null;
@ -382,7 +384,8 @@ class Member extends DataObject implements TemplateGlobalProvider {
$member = DataObject::get_one("Member", "\"Member\".\"ID\" = '$SQL_uid'");
// check if autologin token matches
if($member && (!$member->RememberLoginToken || $member->RememberLoginToken != $token)) {
$hash = $member->encryptWithUserSettings($token);
if($member && (!$member->RememberLoginToken || $member->RememberLoginToken != $hash)) {
$member = null;
}
@ -393,8 +396,10 @@ class Member extends DataObject implements TemplateGlobalProvider {
if(self::$login_marker_cookie) Cookie::set(self::$login_marker_cookie, 1, 0, null, null, false, true);
$generator = new RandomGenerator();
$member->RememberLoginToken = $generator->generateHash('sha1');
Cookie::set('alc_enc', $member->ID . ':' . $member->RememberLoginToken, 90, null, null, false, true);
$token = $generator->randomToken('sha1');
$hash = $member->encryptWithUserSettings($token);
$member->RememberLoginToken = $hash;
Cookie::set('alc_enc', $member->ID . ':' . $token, 90, null, null, false, true);
$member->NumVisit++;
$member->write();
@ -425,27 +430,82 @@ class Member extends DataObject implements TemplateGlobalProvider {
$this->extend('memberLoggedOut');
}
/**
* Utility for generating secure password hashes for this member.
*/
public function encryptWithUserSettings($string) {
if (!$string) return null;
// If the algorithm or salt is not available, it means we are operating
// on legacy account with unhashed password. Do not hash the string.
if (!$this->PasswordEncryption) {
return $string;
}
// We assume we have PasswordEncryption and Salt available here.
$e = PasswordEncryptor::create_for_algorithm($this->PasswordEncryption);
return $e->encrypt($string, $this->Salt);
}
/**
* Generate an auto login hash
*
* This creates an auto login hash that can be used to reset the password.
* Generate an auto login token which can be used to reset the password,
* at the same time hashing it and storing in the database.
*
* @param int $lifetime The lifetime of the auto login hash in days (by default 2 days)
*
* @returns string Token that should be passed to the client (but NOT persisted).
*
* @todo Make it possible to handle database errors such as a "duplicate key" error
*/
public function generateAutologinHash($lifetime = 2) {
public function generateAutologinTokenAndStoreHash($lifetime = 2) {
do {
$generator = new RandomGenerator();
$hash = $generator->generateHash('sha1');
$token = $generator->randomToken();
$hash = $this->encryptWithUserSettings($token);
} while(DataObject::get_one('Member', "\"AutoLoginHash\" = '$hash'"));
$this->AutoLoginHash = $hash;
$this->AutoLoginExpired = date('Y-m-d', time() + (86400 * $lifetime));
$this->write();
return $token;
}
/**
* @deprecated 3.0
*/
public function generateAutologinHash($lifetime = 2) {
Deprecation::notice('3.0',
'Member::generateAutologinHash is deprecated - tokens are no longer saved directly into the database '.
'in plaintext. Use the return value of the Member::generateAutologinTokenAndHash to get the token '.
'instead.',
Deprecation::SCOPE_METHOD);
user_error(
'Member::generateAutologinHash is deprecated - tokens are no longer saved directly into the database '.
'in plaintext. Use the return value of the Member::generateAutologinTokenAndHash to get the token '.
'instead.',
E_USER_ERROR);
}
/**
* Check the token against the member.
*
* @param string $autologinToken
*
* @returns bool Is token valid?
*/
public function validateAutoLoginToken($autologinToken) {
$hash = $this->encryptWithUserSettings($autologinToken);
$member = DataObject::get_one(
'Member',
"\"AutoLoginHash\"='" . $hash . "' AND \"AutoLoginExpired\" > " . DB::getConn()->now()
);
return (bool)$member;
}
/**
@ -467,7 +527,6 @@ class Member extends DataObject implements TemplateGlobalProvider {
return $member;
}
/**
* Send signup, change password or forgot password informations to an user
*

View File

@ -258,12 +258,12 @@ JS
$member = DataObject::get_one('Member', "\"Email\" = '{$SQL_email}'");
if($member) {
$member->generateAutologinHash();
$token = $member->generateAutologinTokenAndStoreHash();
$e = Member_ForgotPasswordEmail::create();
$e->populateTemplate($member);
$e->populateTemplate(array(
'PasswordResetLink' => Security::getPasswordResetLink($member->AutoLoginHash)
'PasswordResetLink' => Security::getPasswordResetLink($member, $token)
));
$e->setTo($member->Email);
$e->send();

View File

@ -99,7 +99,7 @@ abstract class PasswordEncryptor {
*/
public function salt($password, $member = null) {
$generator = new RandomGenerator();
return substr($generator->generateHash('sha1'), 0, 50);
return substr($generator->randomToken('sha1'), 0, 50);
}
/**
@ -265,7 +265,7 @@ class PasswordEncryptor_Blowfish extends PasswordEncryptor {
*/
public function salt($password, $member = null) {
$generator = new RandomGenerator();
return sprintf('%02d', self::$cost) . '$' . substr($generator->generateHash('sha1'), 0, 22);
return sprintf('%02d', self::$cost) . '$' . substr($generator->randomToken('sha1'), 0, 22);
}
public function check($hash, $password, $salt = null, $member = null) {

View File

@ -58,15 +58,31 @@ class RandomGenerator {
// Fallback to good old mt_rand()
return uniqid(mt_rand(), true);
}
/**
* Generates a hash suitable for manual session identifiers, CSRF tokens, etc.
* Generates a random token that can be used for session IDs, CSRF tokens etc., based on
* hash algorithms.
*
* If you are using it as a password equivalent (e.g. autologin token) do NOT store it
* in the database as a plain text but encrypt it with Member::encryptWithUserSettings.
*
* @param String $algorithm Any identifier listed in hash_algos() (Default: whirlpool)
* If possible, choose a slow algorithm which complicates brute force attacks.
*
* @return String Returned length will depend on the used $algorithm
*/
public function generateHash($algorithm = 'whirlpool') {
public function randomToken($algorithm = 'whirlpool') {
return hash($algorithm, $this->generateEntropy());
}
}
}
/**
* @deprecated 3.1
*/
public function generateHash($algorithm = 'whirlpool') {
Deprecation::notice('3.1',
'RandomGenerator::generateHash is deprecated because of a confusing name that hints the output is secure, '.
'while in fact it is just a random string. Use RandomGenerator::randomToken instead.',
Deprecation::SCOPE_METHOD);
return $this->randomToken($algorithm);
}
}

View File

@ -532,15 +532,20 @@ class Security extends Controller {
/**
* Create a link to the password reset form
* Create a link to the password reset form.
*
* @param string $autoLoginHash The auto login hash
* GET parameters used:
* - m: member ID
* - t: plaintext token
*
* @param Member $member Member object associated with this link.
* @param string $autoLoginHash The auto login token.
*/
public static function getPasswordResetLink($autoLoginHash) {
$autoLoginHash = urldecode($autoLoginHash);
public static function getPasswordResetLink($member, $autologinToken) {
$autologinToken = urldecode($autologinToken);
$selfControllerClass = __CLASS__;
$selfController = new $selfControllerClass();
return $selfController->Link('changepassword') . "?h=$autoLoginHash";
return $selfController->Link('changepassword') . "?m={$member->ID}&t=$autologinToken";
}
/**
@ -567,15 +572,22 @@ class Security extends Controller {
$controller = $this;
}
// First load with hash: Redirect to same URL without hash to avoid referer leakage
if(isset($_REQUEST['h']) && Member::member_from_autologinhash($_REQUEST['h'])) {
// The auto login hash is valid, store it for the change password form.
// Temporary value, unset in ChangePasswordForm
Session::set('AutoLoginHash', $_REQUEST['h']);
// Extract the member from the URL.
$member = null;
if (isset($_REQUEST['m'])) {
$member = Member::get()->filter('ID', (int)$_REQUEST['m'])->First();
}
// Check whether we are merely changin password, or resetting.
if(isset($_REQUEST['t']) && $member && $member->validateAutoLoginToken($_REQUEST['t'])) {
// On first valid password reset request redirect to the same URL without hash to avoid referrer leakage.
// Store the hash for the change password form. Will be unset after reload within the ChangePasswordForm.
Session::set('AutoLoginHash', $member->encryptWithUserSettings($_REQUEST['t']));
return $this->redirect($this->Link('changepassword'));
// Redirection target after "First load with hash"
} elseif(Session::get('AutoLoginHash')) {
// Subsequent request after the "first load with hash" (see previous if clause).
$customisedController = $controller->customise(array(
'Content' =>
'<p>' .
@ -584,16 +596,16 @@ class Security extends Controller {
'Form' => $this->ChangePasswordForm(),
));
} elseif(Member::currentUser()) {
// let a logged in user change his password
// Logged in user requested a password change form.
$customisedController = $controller->customise(array(
'Content' => '<p>'
. _t('Security.CHANGEPASSWORDBELOW', 'You can change your password below.') . '</p>',
'Form' => $this->ChangePasswordForm()));
} else {
// show an error message if the auto login hash is invalid and the
// show an error message if the auto login token is invalid and the
// user is not logged in
if(isset($_REQUEST['h'])) {
if(!isset($_REQUEST['t']) || !$member) {
$customisedController = $controller->customise(
array('Content' =>
_t(

View File

@ -227,7 +227,7 @@ class SecurityToken extends Object implements TemplateGlobalProvider {
*/
protected function generate() {
$generator = new RandomGenerator();
return $generator->generateHash('sha1');
return $generator->randomToken('sha1');
}
public static function get_template_global_variables() {
@ -299,4 +299,4 @@ class NullSecurityToken extends SecurityToken {
public function generate() {
return null;
}
}
}

View File

@ -1 +1 @@
<a class="action action-detail edit-link" href="$Link" title="Edit">edit</a>
<a class="action action-detail edit-link" href="$Link" title="<% _t('EDIT', 'Edit') %>"><% _t('EDIT', 'Edit') %></a>

1
tests/behat/README.md Normal file
View File

@ -0,0 +1 @@
See https://github.com/silverstripe-labs/silverstripe-behat-extension

View File

View File

@ -0,0 +1,38 @@
<?php
namespace SilverStripe\Framework\Test\Behaviour;
use SilverStripe\BehatExtension\Context\SilverStripeContext,
SilverStripe\BehatExtension\Context\BasicContext,
SilverStripe\BehatExtension\Context\LoginContext,
SilverStripe\Framework\Test\Behaviour\CmsFormsContext,
SilverStripe\Framework\Test\Behaviour\CmsUiContext;
// PHPUnit
require_once 'PHPUnit/Autoload.php';
require_once 'PHPUnit/Framework/Assert/Functions.php';
/**
* Features context
*
* Context automatically loaded by Behat.
* Uses subcontexts to extend functionality.
*/
class FeatureContext extends SilverStripeContext
{
/**
* Initializes context.
* Every scenario gets it's own context object.
*
* @param array $parameters context parameters (set them up through behat.yml)
*/
public function __construct(array $parameters)
{
$this->useContext('BasicContext', new BasicContext($parameters));
$this->useContext('LoginContext', new LoginContext($parameters));
$this->useContext('CmsFormsContext', new CmsFormsContext($parameters));
$this->useContext('CmsUiContext', new CmsUiContext($parameters));
parent::__construct($parameters);
}
}

View File

@ -0,0 +1,72 @@
<?php
namespace SilverStripe\Framework\Test\Behaviour;
use Behat\Behat\Context\ClosuredContextInterface,
Behat\Behat\Context\TranslatedContextInterface,
Behat\Behat\Context\BehatContext,
Behat\Behat\Context\Step,
Behat\Behat\Exception\PendingException;
use Behat\Gherkin\Node\PyStringNode,
Behat\Gherkin\Node\TableNode;
// PHPUnit
require_once 'PHPUnit/Autoload.php';
require_once 'PHPUnit/Framework/Assert/Functions.php';
/**
* CmsFormsContext
*
* Context used to define steps related to forms inside CMS.
*/
class CmsFormsContext extends BehatContext
{
protected $context;
/**
* Initializes context.
* Every scenario gets it's own context object.
*
* @param array $parameters context parameters (set them up through behat.yml)
*/
public function __construct(array $parameters)
{
// Initialize your context here
$this->context = $parameters;
}
/**
* Get Mink session from MinkContext
*/
public function getSession($name = null)
{
return $this->getMainContext()->getSession($name);
}
/**
* @Then /^I should see an edit page form$/
*/
public function stepIShouldSeeAnEditPageForm()
{
$page = $this->getSession()->getPage();
$form = $page->find('css', '#Form_EditForm');
assertNotNull($form, 'I should see an edit page form');
}
/**
* @When /^I fill in the content form with "([^"]*)"$/
*/
public function stepIFillInTheContentFormWith($content)
{
$this->getSession()->evaluateScript("tinyMCE.get('Form_EditForm_Content').setContent('$content')");
}
/**
* @Then /^the content form should contain "([^"]*)"$/
*/
public function theContentFormShouldContain($content)
{
$this->getMainContext()->assertElementContains('#Form_EditForm_Content', $content);
}
}

View File

@ -0,0 +1,314 @@
<?php
namespace SilverStripe\Framework\Test\Behaviour;
use Behat\Behat\Context\ClosuredContextInterface,
Behat\Behat\Context\TranslatedContextInterface,
Behat\Behat\Context\BehatContext,
Behat\Behat\Context\Step,
Behat\Behat\Exception\PendingException;
use Behat\Gherkin\Node\PyStringNode,
Behat\Gherkin\Node\TableNode;
// PHPUnit
require_once 'PHPUnit/Autoload.php';
require_once 'PHPUnit/Framework/Assert/Functions.php';
/**
* CmsUiContext
*
* Context used to define steps related to SilverStripe CMS UI like Tree or Panel.
*/
class CmsUiContext extends BehatContext
{
protected $context;
/**
* Initializes context.
* Every scenario gets it's own context object.
*
* @param array $parameters context parameters (set them up through behat.yml)
*/
public function __construct(array $parameters)
{
// Initialize your context here
$this->context = $parameters;
}
/**
* Get Mink session from MinkContext
*/
public function getSession($name = null)
{
return $this->getMainContext()->getSession($name);
}
/**
* @Then /^I should see the CMS$/
*/
public function iShouldSeeTheCms()
{
$page = $this->getSession()->getPage();
$cms_element = $page->find('css', '.cms');
assertNotNull($cms_element, 'CMS not found');
}
/**
* @Then /^I should see a "([^"]*)" notice$/
*/
public function iShouldSeeANotice($notice)
{
$this->getMainContext()->assertElementContains('.notice-wrap', $notice);
}
/**
* @Then /^I should see a "([^"]*)" message$/
*/
public function iShouldSeeAMessage($message)
{
$this->getMainContext()->assertElementContains('.message', $message);
}
protected function getCmsTabsElement()
{
$this->getSession()->wait(5000, "window.jQuery('.cms-content-header-tabs').size() > 0");
$page = $this->getSession()->getPage();
$cms_content_header_tabs = $page->find('css', '.cms-content-header-tabs');
assertNotNull($cms_content_header_tabs, 'CMS tabs not found');
return $cms_content_header_tabs;
}
protected function getCmsContentToolbarElement()
{
$this->getSession()->wait(
5000,
"window.jQuery('.cms-content-toolbar').size() > 0 "
. "&& window.jQuery('.cms-content-toolbar').children().size() > 0"
);
$page = $this->getSession()->getPage();
$cms_content_toolbar_element = $page->find('css', '.cms-content-toolbar');
assertNotNull($cms_content_toolbar_element, 'CMS content toolbar not found');
return $cms_content_toolbar_element;
}
protected function getCmsTreeElement()
{
$this->getSession()->wait(5000, "window.jQuery('.cms-tree').size() > 0");
$page = $this->getSession()->getPage();
$cms_tree_element = $page->find('css', '.cms-tree');
assertNotNull($cms_tree_element, 'CMS tree not found');
return $cms_tree_element;
}
protected function getGridfieldTable($title)
{
$page = $this->getSession()->getPage();
$table_elements = $page->findAll('css', '.ss-gridfield-table');
assertNotNull($table_elements, 'Table elements not found');
$table_element = null;
foreach ($table_elements as $table) {
$table_title_element = $table->find('css', '.title');
if ($table_title_element->getText() === $title) {
$table_element = $table;
break;
}
}
assertNotNull($table_element, sprintf('Table `%s` not found', $title));
return $table_element;
}
/**
* @Given /^I should see a "([^"]*)" button in CMS Content Toolbar$/
*/
public function iShouldSeeAButtonInCmsContentToolbar($text)
{
$cms_content_toolbar_element = $this->getCmsContentToolbarElement();
$element = $cms_content_toolbar_element->find('named', array('link_or_button', "'$text'"));
assertNotNull($element, sprintf('%s button not found', $text));
}
/**
* @When /^I should see "([^"]*)" in CMS Tree$/
*/
public function stepIShouldSeeInCmsTree($text)
{
$cms_tree_element = $this->getCmsTreeElement();
$element = $cms_tree_element->find('named', array('content', "'$text'"));
assertNotNull($element, sprintf('%s not found', $text));
}
/**
* @When /^I should not see "([^"]*)" in CMS Tree$/
*/
public function stepIShouldNotSeeInCmsTree($text)
{
$cms_tree_element = $this->getCmsTreeElement();
$element = $cms_tree_element->find('named', array('content', "'$text'"));
assertNull($element, sprintf('%s found', $text));
}
/**
* @When /^I expand the "([^"]*)" CMS Panel$/
*/
public function iExpandTheCmsPanel()
{
// TODO Make dynamic, currently hardcoded to first panel
$page = $this->getSession()->getPage();
$panel_toggle_element = $page->find('css', '.cms-content > .cms-panel > .cms-panel-toggle > .toggle-expand');
assertNotNull($panel_toggle_element, 'Panel toggle not found');
if ($panel_toggle_element->isVisible()) {
$panel_toggle_element->click();
}
}
/**
* @When /^I click the "([^"]*)" CMS tab$/
*/
public function iClickTheCmsTab($tab)
{
$this->getSession()->wait(5000, "window.jQuery('.ui-tabs-nav').size() > 0");
$page = $this->getSession()->getPage();
$tabsets = $page->findAll('css', '.ui-tabs-nav');
assertNotNull($tabsets, 'CMS tabs not found');
$tab_element = null;
foreach($tabsets as $tabset) {
if($tab_element) continue;
$tab_element = $tabset->find('named', array('link_or_button', "'$tab'"));
}
assertNotNull($tab_element, sprintf('%s tab not found', $tab));
$tab_element->click();
}
/**
* @Then /^the "([^"]*)" table should contain "([^"]*)"$/
*/
public function theTableShouldContain($table, $text)
{
$table_element = $this->getGridfieldTable($table);
$element = $table_element->find('named', array('content', "'$text'"));
assertNotNull($element, sprintf('Element containing `%s` not found in `%s` table', $text, $table));
}
/**
* @Then /^the "([^"]*)" table should not contain "([^"]*)"$/
*/
public function theTableShouldNotContain($table, $text)
{
$table_element = $this->getGridfieldTable($table);
$element = $table_element->find('named', array('content', "'$text'"));
assertNull($element, sprintf('Element containing `%s` not found in `%s` table', $text, $table));
}
/**
* @Given /^I click on "([^"]*)" in the "([^"]*)" table$/
*/
public function iClickOnInTheTable($text, $table)
{
$table_element = $this->getGridfieldTable($table);
$element = $table_element->find('xpath', sprintf('//*[count(*)=0 and contains(.,"%s")]', $text));
assertNotNull($element, sprintf('Element containing `%s` not found', $text));
$element->click();
}
/**
* @Then /^I can see the preview panel$/
*/
public function iCanSeeThePreviewPanel()
{
$this->getMainContext()->assertElementOnPage('.cms-preview');
}
/**
* @Given /^the preview contains "([^"]*)"$/
*/
public function thePreviewContains($content)
{
$driver = $this->getSession()->getDriver();
$driver->switchToIFrame('cms-preview-iframe');
$this->getMainContext()->assertPageContainsText($content);
$driver->switchToWindow();
}
/**
* @Given /^the preview does not contain "([^"]*)"$/
*/
public function thePreviewDoesNotContain($content)
{
$driver = $this->getSession()->getDriver();
$driver->switchToIFrame('cms-preview-iframe');
$this->getMainContext()->assertPageNotContainsText($content);
$driver->switchToWindow();
}
/**
* Workaround for chosen.js dropdowns which hide the original dropdown field.
*
* @When /^(?:|I )fill in "(?P<field>(?:[^"]|\\")*)" dropdown with "(?P<value>(?:[^"]|\\")*)"$/
* @When /^(?:|I )fill in "(?P<value>(?:[^"]|\\")*)" for "(?P<field>(?:[^"]|\\")*)" dropdown$/
*/
public function theIFillInTheDropdownWith($field, $value)
{
$field = $this->fixStepArgument($field);
$value = $this->fixStepArgument($value);
$inputField = $this->getSession()->getPage()->findField($field);
if(null === $inputField) {
throw new ElementNotFoundException(sprintf(
'Chosen.js dropdown named "%s" not found',
$field
));
}
$container = $inputField->getParent()->getParent();
if(null === $container) throw new ElementNotFoundException('Chosen.js field container not found');
$linkEl = $container->find('xpath', './/a');
if(null === $linkEl) throw new ElementNotFoundException('Chosen.js link element not found');
$linkEl->click();
$this->getSession()->wait(100); // wait for dropdown overlay to appear
$listEl = $container->find('xpath', sprintf('.//li[contains(normalize-space(string(.)), \'%s\')]', $value));
if(null === $listEl)
{
throw new ElementNotFoundException(sprintf(
'Chosen.js list element with title "%s" not found',
$value
));
}
$listEl->click();
}
/**
* Returns fixed step argument (with \\" replaced back to ").
*
* @param string $argument
*
* @return string
*/
protected function fixStepArgument($argument)
{
return str_replace('\\"', '"', $argument);
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

View File

@ -0,0 +1,20 @@
# features/login.feature
Feature: Log in
As an site owner
I want to access to the CMS to be secure
So that only my team can make content changes
Scenario: Bad login
Given I log in with "bad@example.com" and "badpassword"
Then I will see a bad log-in message
Scenario: Valid login
Given I am logged in with "ADMIN" permissions
When I go to "/admin/"
Then I should see the CMS
Scenario: /admin/ redirect for not logged in user
# disable automatic redirection so we can use the profiler
When I go to "/admin/" without redirection
Then I should be redirected to "/Security/login"
And I should see a log-in form

View File

@ -0,0 +1,84 @@
@javascript @assets
Feature: Manage files
As a cms author
I want to upload and manage files within the CMS
So that I can insert them into my content efficiently
Background:
# Idea: We could weave the database reset into this through
# saying 'Given there are ONLY the following...'.
Given there are the following Folder records
"""
folder1:
Filename: assets/folder1
folder1.1:
Filename: assets/folder1/folder1.1
Parent: =>Folder.folder1
folder2:
Filename: assets/folder2
Name: folder2
"""
And there are the following File records
"""
file1:
Filename: assets/folder1/file1.jpg
Name: file1.jpg
Parent: =>Folder.folder1
file2:
Filename: assets/folder1/folder1.1/file2.jpg
Name: file2.jpg
Parent: =>Folder.folder1.1
"""
And I am logged in with "ADMIN" permissions
# Alternative fixture shortcuts, with their titles
# as shown in admin/security rather than technical permission codes.
# Just an idea for now, could be handled by YAML fixtures as well
# And I am logged in with the following permissions
# - Access to 'Pages' section
# - Access to 'Files' section
And I go to "/admin/assets"
@modal
Scenario: I can add a new folder
Given I press the "Add folder" button
And I type "newfolder" into the dialog
And I confirm the dialog
Then the "Files" table should contain "newfolder"
Scenario: I can list files in a folder
Given I click on "folder1" in the "Files" table
Then the "folder1" table should contain "file1"
And the "folder1" table should not contain "file1.1"
Scenario: I can upload a file to a folder
Given I click on "folder1" in the "Files" table
And I press the "Upload" button
And I attach the file "testfile.jpg" to "AssetUploadField" with HTML5
And I wait for 5 seconds
And I press the "Back to folder" button
Then the "folder1" table should contain "testfile"
Scenario: I can edit a file
Given I click on "folder1" in the "Files" table
And I click on "file1" in the "folder1" table
And I fill in "renamedfile" for "Title"
And I press the "Save" button
And I press the "Back" button
Then the "folder1" table should not contain "testfile"
And the "folder1" table should contain "renamedfile"
Scenario: I can delete a file
Given I click on "folder1" in the "Files" table
And I click on "file1" in the "folder1" table
And I press the "Delete" button
Then the "folder1" table should not contain "file1"
Scenario: I can change the folder of a file
Given I click on "folder1" in the "Files" table
And I click on "file1" in the "folder1" table
And I fill in =>Folder.folder2 for "ParentID"
And I press the "Save" button
# /show/0 is to ensure that we are on top level folder
And I go to "/admin/assets/show/0"
And I click on "folder2" in the "Files" table
And the "folder2" table should contain "file1"

View File

@ -0,0 +1,87 @@
@database-defaults
Feature: Manage users
As a site administrator
I want to create and manage user accounts on my site
So that I can control access to the CMS
Background:
Given there are the following Permission records
"""
admin:
Code: ADMIN
security-admin:
Code: CMS_ACCESS_SecurityAdmin
"""
And there are the following Group records
"""
admingroup:
Title: Admin Group
Code: admin
Permissions: =>Permission.admin
staffgroup:
Title: Staff Group
Code: staffgroup
"""
And there are the following Member records
"""
admin:
FirstName: Admin
Email: admin@test.com
Groups: =>Group.admingroup
staffmember:
FirstName: Staff
Email: staffmember@test.com
Groups: =>Group.staffgroup
"""
And I am logged in with "ADMIN" permissions
And I go to "/admin/security"
@javascript
Scenario: I can list all users regardless of group
When I click the "Users" CMS tab
Then I should see "admin@test.com" in the "#Root_Users" element
And I should see "staffmember@test.com" in the "#Root_Users" element
@javascript
Scenario: I can list all users in a specific group
When I click the "Groups" CMS tab
# TODO Please check how performant this is
And I click "Admin Group" in the "#Root_Groups" element
Then I should see "admin@test.com" in the "#Root_Members" element
And I should not see "staffmember@test.com" in the "#Root_Members" element
@javascript
Scenario: I can add a user to the system
When I click the "Users" CMS tab
And I press the "Add Member" button
And I fill in the following:
| First Name | John |
| Surname | Doe |
| Email | john.doe@test.com |
And I press the "Create" button
Then I should see a "Saved member" message
When I go to "admin/security/"
Then I should see "john.doe@test.com" in the "#Root_Users" element
@javascript
Scenario: I can edit an existing user and add him to an existing group
When I click the "Users" CMS tab
And I click "staffmember@test.com" in the "#Root_Users" element
And I select "Admin Group" from "Groups"
And I additionally select "Administrators" from "Groups"
And I press the "Save" button
Then I should see a "Saved Member" message
When I go to "admin/security"
And I click the "Groups" CMS tab
And I click "Admin Group" in the "#Root_Groups" element
Then I should see "staffmember@test.com"
@javascript
Scenario: I can delete an existing user
When I click the "Users" CMS tab
And I click "staffmember@test.com" in the "#Root_Users" element
And I press the "Delete" button
Then I should see "admin@test.com"
And I should not see "staffmember@test.com"

View File

@ -153,7 +153,7 @@ class FormTest extends FunctionalTest {
$captainWithDetails = $this->objFromFixture('FormTest_Player', 'captainNoDetails');
$team2 = $this->objFromFixture('FormTest_Team', 'team2');
$form->loadDataFrom($captainWithDetails);
$form->loadDataFrom($team2, true);
$form->loadDataFrom($team2, Form::MERGE_CLEAR_MISSING);
$this->assertEquals(
$form->getData(),
array(
@ -166,7 +166,35 @@ class FormTest extends FunctionalTest {
'LoadDataFrom() overwrites fields not found in the object with $clearMissingFields=true'
);
}
public function testLoadDataFromIgnoreFalseish() {
$form = new Form(
new Controller(),
'Form',
new FieldList(
new TextField('Biography', 'Biography', 'Custom Default')
),
new FieldList()
);
$captainNoDetails = $this->objFromFixture('FormTest_Player', 'captainNoDetails');
$captainWithDetails = $this->objFromFixture('FormTest_Player', 'captainWithDetails');
$form->loadDataFrom($captainNoDetails, Form::MERGE_IGNORE_FALSEISH);
$this->assertEquals(
$form->getData(),
array('Biography' => 'Custom Default'),
'LoadDataFrom() doesn\'t overwrite fields when MERGE_IGNORE_FALSEISH set and values are false-ish'
);
$form->loadDataFrom($captainWithDetails, Form::MERGE_IGNORE_FALSEISH);
$this->assertEquals(
$form->getData(),
array('Biography' => 'Bio 1'),
'LoadDataFrom() does overwrite fields when MERGE_IGNORE_FALSEISH set and values arent false-ish'
);
}
public function testFormMethodOverride() {
$form = $this->getStubForm();
$form->setFormMethod('GET');

View File

@ -443,15 +443,15 @@ class ArrayListTest extends SapphireTest {
*/
public function testSimpleExclude() {
$list = new ArrayList(array(
0=>array('Name' => 'Steve'),
1=>array('Name' => 'Bob'),
2=>array('Name' => 'John')
array('Name' => 'Steve'),
array('Name' => 'Bob'),
array('Name' => 'John')
));
$list->exclude('Name', 'Bob');
$expected = array(
0=>array('Name' => 'Steve'),
2=>array('Name' => 'John')
array('Name' => 'Steve'),
array('Name' => 'John')
);
$this->assertEquals(2, $list->count());
$this->assertEquals($expected, $list->toArray(), 'List should not contain Bob');
@ -481,12 +481,12 @@ class ArrayListTest extends SapphireTest {
*/
public function testSimpleExcludeWithArray() {
$list = new ArrayList(array(
0=>array('Name' => 'Steve'),
1=>array('Name' => 'Bob'),
2=>array('Name' => 'John')
array('Name' => 'Steve'),
array('Name' => 'Bob'),
array('Name' => 'John')
));
$list->exclude('Name', array('Steve','John'));
$expected = array(1=>array('Name' => 'Bob'));
$expected = array(array('Name' => 'Bob'));
$this->assertEquals(1, $list->count());
$this->assertEquals($expected, $list->toArray(), 'List should only contain Bob');
}
@ -496,16 +496,16 @@ class ArrayListTest extends SapphireTest {
*/
public function testExcludeWithTwoArrays() {
$list = new ArrayList(array(
0=>array('Name' => 'Bob' , 'Age' => 21),
1=>array('Name' => 'Bob' , 'Age' => 32),
2=>array('Name' => 'John', 'Age' => 21)
array('Name' => 'Bob' , 'Age' => 21),
array('Name' => 'Bob' , 'Age' => 32),
array('Name' => 'John', 'Age' => 21)
));
$list->exclude(array('Name' => 'Bob', 'Age' => 21));
$expected = array(
1=>array('Name' => 'Bob', 'Age' => 32),
2=>array('Name' => 'John', 'Age' => 21)
array('Name' => 'Bob', 'Age' => 32),
array('Name' => 'John', 'Age' => 21)
);
$this->assertEquals(2, $list->count());
@ -517,23 +517,23 @@ class ArrayListTest extends SapphireTest {
*/
public function testMultipleExclude() {
$list = new ArrayList(array(
0 => array('Name' => 'bob', 'Age' => 10),
1 => array('Name' => 'phil', 'Age' => 11),
2 => array('Name' => 'bob', 'Age' => 12),
3 => array('Name' => 'phil', 'Age' => 12),
4 => array('Name' => 'bob', 'Age' => 14),
5 => array('Name' => 'phil', 'Age' => 14),
6 => array('Name' => 'bob', 'Age' => 16),
7 => array('Name' => 'phil', 'Age' => 16)
array('Name' => 'bob', 'Age' => 10),
array('Name' => 'phil', 'Age' => 11),
array('Name' => 'bob', 'Age' => 12),
array('Name' => 'phil', 'Age' => 12),
array('Name' => 'bob', 'Age' => 14),
array('Name' => 'phil', 'Age' => 14),
array('Name' => 'bob', 'Age' => 16),
array('Name' => 'phil', 'Age' => 16)
));
$list->exclude(array('Name'=>array('bob','phil'),'Age'=>array(10, 16)));
$expected = array(
1 => array('Name' => 'phil', 'Age' => 11),
2 => array('Name' => 'bob', 'Age' => 12),
3 => array('Name' => 'phil', 'Age' => 12),
4 => array('Name' => 'bob', 'Age' => 14),
5 => array('Name' => 'phil', 'Age' => 14),
array('Name' => 'phil', 'Age' => 11),
array('Name' => 'bob', 'Age' => 12),
array('Name' => 'phil', 'Age' => 12),
array('Name' => 'bob', 'Age' => 14),
array('Name' => 'phil', 'Age' => 14),
);
$this->assertEquals($expected, $list->toArray());
}
@ -543,26 +543,26 @@ class ArrayListTest extends SapphireTest {
*/
public function testMultipleExcludeNoMatch() {
$list = new ArrayList(array(
0 => array('Name' => 'bob', 'Age' => 10),
1 => array('Name' => 'phil', 'Age' => 11),
2 => array('Name' => 'bob', 'Age' => 12),
3 => array('Name' => 'phil', 'Age' => 12),
4 => array('Name' => 'bob', 'Age' => 14),
5 => array('Name' => 'phil', 'Age' => 14),
6 => array('Name' => 'bob', 'Age' => 16),
7 => array('Name' => 'phil', 'Age' => 16)
array('Name' => 'bob', 'Age' => 10),
array('Name' => 'phil', 'Age' => 11),
array('Name' => 'bob', 'Age' => 12),
array('Name' => 'phil', 'Age' => 12),
array('Name' => 'bob', 'Age' => 14),
array('Name' => 'phil', 'Age' => 14),
array('Name' => 'bob', 'Age' => 16),
array('Name' => 'phil', 'Age' => 16)
));
$list->exclude(array('Name'=>array('bob','phil'),'Age'=>array(10, 16),'Bananas'=>true));
$expected = array(
0 => array('Name' => 'bob', 'Age' => 10),
1 => array('Name' => 'phil', 'Age' => 11),
2 => array('Name' => 'bob', 'Age' => 12),
3 => array('Name' => 'phil', 'Age' => 12),
4 => array('Name' => 'bob', 'Age' => 14),
5 => array('Name' => 'phil', 'Age' => 14),
6 => array('Name' => 'bob', 'Age' => 16),
7 => array('Name' => 'phil', 'Age' => 16)
array('Name' => 'bob', 'Age' => 10),
array('Name' => 'phil', 'Age' => 11),
array('Name' => 'bob', 'Age' => 12),
array('Name' => 'phil', 'Age' => 12),
array('Name' => 'bob', 'Age' => 14),
array('Name' => 'phil', 'Age' => 14),
array('Name' => 'bob', 'Age' => 16),
array('Name' => 'phil', 'Age' => 16)
);
$this->assertEquals($expected, $list->toArray());
}
@ -572,29 +572,29 @@ class ArrayListTest extends SapphireTest {
*/
public function testMultipleExcludeThreeArguments() {
$list = new ArrayList(array(
0 => array('Name' => 'bob', 'Age' => 10, 'HasBananas'=>false),
1 => array('Name' => 'phil','Age' => 11, 'HasBananas'=>true),
2 => array('Name' => 'bob', 'Age' => 12, 'HasBananas'=>true),
3 => array('Name' => 'phil','Age' => 12, 'HasBananas'=>true),
4 => array('Name' => 'bob', 'Age' => 14, 'HasBananas'=>false),
4 => array('Name' => 'ann', 'Age' => 14, 'HasBananas'=>true),
5 => array('Name' => 'phil','Age' => 14, 'HasBananas'=>false),
6 => array('Name' => 'bob', 'Age' => 16, 'HasBananas'=>false),
7 => array('Name' => 'phil','Age' => 16, 'HasBananas'=>true),
8 => array('Name' => 'clair','Age' => 16, 'HasBananas'=>true)
array('Name' => 'bob', 'Age' => 10, 'HasBananas'=>false),
array('Name' => 'phil','Age' => 11, 'HasBananas'=>true),
array('Name' => 'bob', 'Age' => 12, 'HasBananas'=>true),
array('Name' => 'phil','Age' => 12, 'HasBananas'=>true),
array('Name' => 'bob', 'Age' => 14, 'HasBananas'=>false),
array('Name' => 'ann', 'Age' => 14, 'HasBananas'=>true),
array('Name' => 'phil','Age' => 14, 'HasBananas'=>false),
array('Name' => 'bob', 'Age' => 16, 'HasBananas'=>false),
array('Name' => 'phil','Age' => 16, 'HasBananas'=>true),
array('Name' => 'clair','Age' => 16, 'HasBananas'=>true)
));
$list->exclude(array('Name'=>array('bob','phil'),'Age'=>array(10, 16),'HasBananas'=>true));
$expected = array(
0 => array('Name' => 'bob', 'Age' => 10, 'HasBananas'=>false),
1 => array('Name' => 'phil','Age' => 11, 'HasBananas'=>true),
2 => array('Name' => 'bob', 'Age' => 12, 'HasBananas'=>true),
3 => array('Name' => 'phil','Age' => 12, 'HasBananas'=>true),
4 => array('Name' => 'bob', 'Age' => 14, 'HasBananas'=>false),
4 => array('Name' => 'ann', 'Age' => 14, 'HasBananas'=>true),
5 => array('Name' => 'phil','Age' => 14, 'HasBananas'=>false),
6 => array('Name' => 'bob', 'Age' => 16, 'HasBananas'=>false),
8 => array('Name' => 'clair','Age' => 16, 'HasBananas'=>true)
array('Name' => 'bob', 'Age' => 10, 'HasBananas'=>false),
array('Name' => 'phil','Age' => 11, 'HasBananas'=>true),
array('Name' => 'bob', 'Age' => 12, 'HasBananas'=>true),
array('Name' => 'phil','Age' => 12, 'HasBananas'=>true),
array('Name' => 'bob', 'Age' => 14, 'HasBananas'=>false),
array('Name' => 'ann', 'Age' => 14, 'HasBananas'=>true),
array('Name' => 'phil','Age' => 14, 'HasBananas'=>false),
array('Name' => 'bob', 'Age' => 16, 'HasBananas'=>false),
array('Name' => 'clair','Age' => 16, 'HasBananas'=>true)
);
$this->assertEquals($expected, $list->toArray());
}

View File

@ -749,8 +749,14 @@ class DataObjectTest extends SapphireTest {
$obj = new DataObjectTest_Fixture();
$this->assertEquals(
$obj->MyFieldWithDefault,
"Default Value",
"Defaults are populated for in-memory object from \$defaults array"
'Default Value',
'Defaults are populated for in-memory object from $defaults array'
);
$this->assertEquals(
$obj->MyFieldWithAltDefault,
'Default Value',
'Defaults are populated from overloaded populateDefaults() method'
);
}
@ -1108,25 +1114,7 @@ class DataObjectTest extends SapphireTest {
DataObject::get();
}
public function testWriteOverwritesCreated() {
// Previously, if you set DataObject::$Created before the object was first written, it would be overwritten
$pastdate = new SS_DateTime();
$pastdate->setValue(Date::past_date(1));
$obj = new DataObjectTest_Player();
$obj->Created = $pastdate->Value;
$obj->write();
$objID = $obj->ID;
unset($obj);
DataObject::reset();
$obj = DataObjectTest_Player::get()->byID($objID);
$this->assertEquals($pastdate->Value, $obj->Created);
}
}
class DataObjectTest_Player extends Member implements TestOnly {
@ -1190,18 +1178,25 @@ class DataObjectTest_Fixture extends DataObject implements TestOnly {
'Data' => 'Varchar',
'Duplicate' => 'Varchar',
'DbObject' => 'Varchar',
// Field with default
'MyField' => 'Varchar',
// Field types
"DateField" => "Date",
"DatetimeField" => "Datetime",
'DateField' => 'Date',
'DatetimeField' => 'Datetime',
'MyFieldWithDefault' => 'Varchar',
'MyFieldWithAltDefault' => 'Varchar'
);
static $defaults = array(
'MyFieldWithDefault' => 'Default Value',
'MyFieldWithDefault' => 'Default Value',
);
public function populateDefaults() {
parent::populateDefaults();
$this->MyFieldWithAltDefault = 'Default Value';
}
}
class DataObjectTest_SubTeam extends DataObjectTest_Team implements TestOnly {
@ -1293,6 +1288,7 @@ class DataObjectTest_TeamComment extends DataObject {
static $has_one = array(
'Team' => 'DataObjectTest_Team'
);
}
DataObjectTest_Team::add_extension('DataObjectTest_Team_Extension');

View File

@ -2,7 +2,7 @@
class SQLQueryTest extends SapphireTest {
static $fixture_file = null;
public static $fixture_file = 'SQLQueryTest.yml';
protected $extraDataObjects = array(
'SQLQueryTest_DO',
@ -300,6 +300,72 @@ class SQLQueryTest extends SapphireTest {
$query->setWhereAny(array("Monkey = 'Chimp'", "Color = 'Brown'"));
$this->assertEquals("SELECT * FROM MyTable WHERE (Monkey = 'Chimp' OR Color = 'Brown')",$query->sql());
}
public function testSelectFirst() {
// Test first from sequence
$query = new SQLQuery();
$query->setFrom('"SQLQueryTest_DO"');
$query->setOrderBy('"Name"');
$result = $query->firstRow()->execute();
$this->assertCount(1, $result);
foreach($result as $row) {
$this->assertEquals('Object 1', $row['Name']);
}
// Test first from empty sequence
$query = new SQLQuery();
$query->setFrom('"SQLQueryTest_DO"');
$query->setOrderBy('"Name"');
$query->setWhere(array("\"Name\" = 'Nonexistent Object'"));
$result = $query->firstRow()->execute();
$this->assertCount(0, $result);
// Test that given the last item, the 'first' in this list matches the last
$query = new SQLQuery();
$query->setFrom('"SQLQueryTest_DO"');
$query->setOrderBy('"Name"');
$query->setLimit(1, 1);
$result = $query->firstRow()->execute();
$this->assertCount(1, $result);
foreach($result as $row) {
$this->assertEquals('Object 2', $row['Name']);
}
}
public function testSelectLast() {
// Test last in sequence
$query = new SQLQuery();
$query->setFrom('"SQLQueryTest_DO"');
$query->setOrderBy('"Name"');
$result = $query->lastRow()->execute();
$this->assertCount(1, $result);
foreach($result as $row) {
$this->assertEquals('Object 2', $row['Name']);
}
// Test last from empty sequence
$query = new SQLQuery();
$query->setFrom('"SQLQueryTest_DO"');
$query->setOrderBy('"Name"');
$query->setWhere(array("\"Name\" = 'Nonexistent Object'"));
$result = $query->lastRow()->execute();
$this->assertCount(0, $result);
// Test that given the first item, the 'last' in this list matches the first
$query = new SQLQuery();
$query->setFrom('"SQLQueryTest_DO"');
$query->setOrderBy('"Name"');
$query->setLimit(1);
$result = $query->lastRow()->execute();
$this->assertCount(1, $result);
foreach($result as $row) {
$this->assertEquals('Object 1', $row['Name']);
}
}
}

View File

@ -0,0 +1,7 @@
SQLQueryTest_DO:
test1:
Name: 'Object 1'
Meta: 'Details 1'
test2:
Name: 'Object 2'
Meta: 'Details 2'

View File

@ -624,6 +624,35 @@ class MemberTest extends FunctionalTest {
return $extensions;
}
public function testGenerateAutologinTokenAndStoreHash() {
$enc = new PasswordEncryptor_Blowfish();
$m = new Member();
$m->PasswordEncryption = 'blowfish';
$m->Salt = $enc->salt('123');
$token = $m->generateAutologinTokenAndStoreHash();
$this->assertEquals($m->encryptWithUserSettings($token), $m->AutoLoginHash, 'Stores the token as ahash.');
}
public function testValidateAutoLoginToken() {
$enc = new PasswordEncryptor_Blowfish();
$m1 = new Member();
$m1->PasswordEncryption = 'blowfish';
$m1->Salt = $enc->salt('123');
$m1Token = $m1->generateAutologinTokenAndStoreHash();
$m2 = new Member();
$m2->PasswordEncryption = 'blowfish';
$m2->Salt = $enc->salt('456');
$m2Token = $m2->generateAutologinTokenAndStoreHash();
$this->assertTrue($m1->validateAutoLoginToken($m1Token), 'Passes token validity test against matching member.');
$this->assertFalse($m2->validateAutoLoginToken($m1Token), 'Fails token validity test against other member.');
}
}
class MemberTest_ViewingAllowedExtension extends DataExtension implements TestOnly {

View File

@ -14,14 +14,14 @@ class RandomGeneratorTest extends SapphireTest {
public function testGenerateHash() {
$r = new RandomGenerator();
$this->assertNotNull($r->generateHash());
$this->assertNotEquals($r->generateHash(), $r->generateHash());
$this->assertNotNull($r->randomToken());
$this->assertNotEquals($r->randomToken(), $r->randomToken());
}
public function testGenerateHashWithAlgorithm() {
$r = new RandomGenerator();
$this->assertNotNull($r->generateHash('md5'));
$this->assertNotEquals($r->generateHash(), $r->generateHash('md5'));
$this->assertNotNull($r->randomToken('md5'));
$this->assertNotEquals($r->randomToken(), $r->randomToken('md5'));
}
}

View File

@ -222,7 +222,12 @@ class SecurityTest extends FunctionalTest {
// Load password link from email
$admin = DataObject::get_by_id('Member', $admin->ID);
$this->assertNotNull($admin->AutoLoginHash, 'Hash has been written after lost password');
$response = $this->get('Security/changepassword/?h=' . $admin->AutoLoginHash);
// We don't have access to the token - generate a new token and hash pair.
$token = $admin->generateAutologinTokenAndStoreHash();
// Check.
$response = $this->get('Security/changepassword/?m='.$admin->ID.'&t=' . $token);
$this->assertEquals(302, $response->getStatusCode());
$this->assertEquals(Director::baseUrl() . 'Security/changepassword', $response->getHeader('Location'));

View File

@ -1022,7 +1022,9 @@ class Requirements_Backend {
// file exists, check modification date of every contained file
$srcLastMod = 0;
foreach($fileList as $file) {
$srcLastMod = max(filemtime($base . $file), $srcLastMod);
if(file_exists($base . $file)) {
$srcLastMod = max(filemtime($base . $file), $srcLastMod);
}
}
$refresh = $srcLastMod > filemtime($combinedFilePath);
} else {